inference-vitals 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Urban Morph and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: inference-vitals
3
+ Version: 0.2.0
4
+ Summary: A dashboard for running and fine-tuning AI models on an Apple Silicon Mac or Linux with NVIDIA GPUs: speed, loss, memory, heat, power and battery
5
+ Keywords: llm,inference,fine-tuning,apple-silicon,mlx,nvidia,prometheus,dashboard,monitoring
6
+ Author: Urban Morph
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: System :: Monitoring
21
+ Requires-Python: >=3.11
22
+ Project-URL: Homepage, https://github.com/urbanmorph/inference-vitals
23
+ Project-URL: Documentation, https://github.com/urbanmorph/inference-vitals/tree/main/docs
24
+ Project-URL: Issues, https://github.com/urbanmorph/inference-vitals/issues
25
+ Project-URL: Changelog, https://github.com/urbanmorph/inference-vitals/releases
26
+ Description-Content-Type: text/markdown
27
+
28
+ <h1>
29
+ <picture>
30
+ <source media="(prefers-color-scheme: dark)" srcset="https://github.com/urbanmorph/inference-vitals/raw/v0.2.0/docs/brand/logo-dark.svg">
31
+ <img alt="inference-vitals" src="https://github.com/urbanmorph/inference-vitals/raw/v0.2.0/docs/brand/logo-light.svg" height="48">
32
+ </picture>
33
+ </h1>
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/inference-vitals)](https://pypi.org/project/inference-vitals/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/inference-vitals)](https://pypi.org/project/inference-vitals/)
37
+ [![Licence: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/LICENSE)
38
+ [![Tests](https://github.com/urbanmorph/inference-vitals/actions/workflows/test.yml/badge.svg)](https://github.com/urbanmorph/inference-vitals/actions/workflows/test.yml)
39
+ [![Apple Silicon · Linux with NVIDIA](https://img.shields.io/badge/runs_on-Apple_Silicon_%C2%B7_Linux_%2B_NVIDIA-555)](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/install.md)
40
+
41
+ **One screen for running and fine-tuning AI models on an Apple Silicon Mac, or on Linux with NVIDIA GPUs
42
+ (experimental): how fast the model is (or how well it is learning), how close it is to running out of
43
+ memory, and how hot the machine is getting, on the same timeline.**
44
+
45
+ ![At a glance, during a run: speed, context, answers, memory, swap, GPU, heat and power](https://github.com/urbanmorph/inference-vitals/raw/v0.2.0/docs/screenshots/at-a-glance-dark.png)
46
+
47
+ Activity Monitor (or `nvtop`) makes you switch views and knows nothing about models. Benchmark apps time
48
+ the requests they send themselves. inference-vitals sits beside whatever you are running and records both
49
+ sides, the model and the machine, so you can see *why* a run slowed down: the GPU got hot, the system
50
+ started swapping, or a page was simply longer.
51
+
52
+ ## What you get
53
+
54
+ - **Model runs**, with any local engine (Ollama, LM Studio, llama.cpp, the MLX servers, Jan, LocalAI) or
55
+ your own code: speed live while the answer is written, time to first token, context used, answers cut
56
+ off or invalid.
57
+ - **Fine-tunes**: training and validation loss, steps and time left, and whether the model is still
58
+ learning or has started memorising its examples.
59
+ - **The machine**: GPU memory left, memory pressure, swap, GPU use, temperatures, thermal state, power.
60
+ - **Energy**: what the machine used over any range, how much of it was the AI, and the cost at your rate.
61
+ - **Battery** on a laptop: the charge, and on battery how long it will last at this load, with a warning
62
+ when it will not outlast the run.
63
+
64
+ It fits one screen: the live numbers stay in view while tabs swap the charts below them, and you star the
65
+ charts you want together in **Favourites**. Every tile and tab: [docs/dashboard.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/dashboard.md).
66
+
67
+ ## Quick start
68
+
69
+ On an Apple Silicon Mac with [Homebrew](https://brew.sh):
70
+
71
+ ```sh
72
+ brew install uv prometheus macmon
73
+ uv tool install inference-vitals
74
+ inference-vitals start
75
+ open http://127.0.0.1:3000
76
+ ```
77
+
78
+ The machine's numbers appear straight away. Linux with NVIDIA, Grafana, troubleshooting:
79
+ [docs/install.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/install.md).
80
+
81
+ ## Connect what you run
82
+
83
+ | You run… | Then | More |
84
+ | --- | --- | --- |
85
+ | An engine with an HTTP API (Ollama, LM Studio, llama.cpp, MLX servers) | `inference-vitals proxy --to http://127.0.0.1:11434` and point your app at `http://127.0.0.1:8401` | [connect.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/connect.md) |
86
+ | Your own Python code | Three calls from `inference_vitals.metrics` | [connect.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/connect.md#your-own-python-code-the-library) |
87
+ | A fine-tune (mlx-vlm, mlx-lm, or any loop) | `inference-vitals train -- <your training command>` | [training.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/training.md) |
88
+
89
+ ## Privacy
90
+
91
+ Everything runs on your machine and listens on `127.0.0.1` only; the page loads nothing from the internet;
92
+ only numbers are collected, never prompts, answers or file names. More: [docs/limits.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/limits.md).
93
+
94
+ ## Documentation
95
+
96
+ | | |
97
+ | --- | --- |
98
+ | [Install](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/install.md) | Mac and Linux, commands, Grafana, troubleshooting, uninstall |
99
+ | [Connect](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/connect.md) | The proxy and the Python library for model runs; run conditions |
100
+ | [Training](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/training.md) | Following a fine-tune three ways; how "Learning?" is decided |
101
+ | [Dashboard](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/dashboard.md) | Every tile, training mode, energy and cost, battery, address options |
102
+ | [Metrics](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/metrics.md) | Every metric name, for other tools that read Prometheus |
103
+ | [Limits](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/limits.md) | Privacy in full, overhead, what it cannot show |
104
+ | [AGENTS.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/AGENTS.md) | For coding agents helping you: tasks, checks, symptoms, rules |
105
+ | [CONTRIBUTING.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/CONTRIBUTING.md) | Tests, checks, adding a trainer, where things are |
106
+
107
+ It is [Prometheus](https://prometheus.io) with a dashboard page of its own (or
108
+ [Grafana](https://grafana.com)), [macmon](https://github.com/vladkens/macmon) for the chip on a Mac (on
109
+ Linux, a small exporter that reads `nvidia-smi`), small exporters for memory and the battery, the proxy,
110
+ and a tiny Python library, with no dependencies beyond Python itself.
111
+
112
+ ## Credits
113
+
114
+ inference-vitals runs [macmon](https://github.com/vladkens/macmon) (MIT) and
115
+ [Prometheus](https://github.com/prometheus/prometheus) (Apache-2.0), and optionally
116
+ [Grafana](https://github.com/grafana/grafana) (AGPL-3.0), installed separately (Homebrew on a Mac, your
117
+ package manager on Linux); none of them is bundled here.
118
+
119
+ ## Licence
120
+
121
+ MIT. See [LICENSE](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/LICENSE).
@@ -0,0 +1,94 @@
1
+ <h1>
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://github.com/urbanmorph/inference-vitals/raw/v0.2.0/docs/brand/logo-dark.svg">
4
+ <img alt="inference-vitals" src="https://github.com/urbanmorph/inference-vitals/raw/v0.2.0/docs/brand/logo-light.svg" height="48">
5
+ </picture>
6
+ </h1>
7
+
8
+ [![PyPI](https://img.shields.io/pypi/v/inference-vitals)](https://pypi.org/project/inference-vitals/)
9
+ [![Python](https://img.shields.io/pypi/pyversions/inference-vitals)](https://pypi.org/project/inference-vitals/)
10
+ [![Licence: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/LICENSE)
11
+ [![Tests](https://github.com/urbanmorph/inference-vitals/actions/workflows/test.yml/badge.svg)](https://github.com/urbanmorph/inference-vitals/actions/workflows/test.yml)
12
+ [![Apple Silicon · Linux with NVIDIA](https://img.shields.io/badge/runs_on-Apple_Silicon_%C2%B7_Linux_%2B_NVIDIA-555)](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/install.md)
13
+
14
+ **One screen for running and fine-tuning AI models on an Apple Silicon Mac, or on Linux with NVIDIA GPUs
15
+ (experimental): how fast the model is (or how well it is learning), how close it is to running out of
16
+ memory, and how hot the machine is getting, on the same timeline.**
17
+
18
+ ![At a glance, during a run: speed, context, answers, memory, swap, GPU, heat and power](https://github.com/urbanmorph/inference-vitals/raw/v0.2.0/docs/screenshots/at-a-glance-dark.png)
19
+
20
+ Activity Monitor (or `nvtop`) makes you switch views and knows nothing about models. Benchmark apps time
21
+ the requests they send themselves. inference-vitals sits beside whatever you are running and records both
22
+ sides, the model and the machine, so you can see *why* a run slowed down: the GPU got hot, the system
23
+ started swapping, or a page was simply longer.
24
+
25
+ ## What you get
26
+
27
+ - **Model runs**, with any local engine (Ollama, LM Studio, llama.cpp, the MLX servers, Jan, LocalAI) or
28
+ your own code: speed live while the answer is written, time to first token, context used, answers cut
29
+ off or invalid.
30
+ - **Fine-tunes**: training and validation loss, steps and time left, and whether the model is still
31
+ learning or has started memorising its examples.
32
+ - **The machine**: GPU memory left, memory pressure, swap, GPU use, temperatures, thermal state, power.
33
+ - **Energy**: what the machine used over any range, how much of it was the AI, and the cost at your rate.
34
+ - **Battery** on a laptop: the charge, and on battery how long it will last at this load, with a warning
35
+ when it will not outlast the run.
36
+
37
+ It fits one screen: the live numbers stay in view while tabs swap the charts below them, and you star the
38
+ charts you want together in **Favourites**. Every tile and tab: [docs/dashboard.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/dashboard.md).
39
+
40
+ ## Quick start
41
+
42
+ On an Apple Silicon Mac with [Homebrew](https://brew.sh):
43
+
44
+ ```sh
45
+ brew install uv prometheus macmon
46
+ uv tool install inference-vitals
47
+ inference-vitals start
48
+ open http://127.0.0.1:3000
49
+ ```
50
+
51
+ The machine's numbers appear straight away. Linux with NVIDIA, Grafana, troubleshooting:
52
+ [docs/install.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/install.md).
53
+
54
+ ## Connect what you run
55
+
56
+ | You run… | Then | More |
57
+ | --- | --- | --- |
58
+ | An engine with an HTTP API (Ollama, LM Studio, llama.cpp, MLX servers) | `inference-vitals proxy --to http://127.0.0.1:11434` and point your app at `http://127.0.0.1:8401` | [connect.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/connect.md) |
59
+ | Your own Python code | Three calls from `inference_vitals.metrics` | [connect.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/connect.md#your-own-python-code-the-library) |
60
+ | A fine-tune (mlx-vlm, mlx-lm, or any loop) | `inference-vitals train -- <your training command>` | [training.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/training.md) |
61
+
62
+ ## Privacy
63
+
64
+ Everything runs on your machine and listens on `127.0.0.1` only; the page loads nothing from the internet;
65
+ only numbers are collected, never prompts, answers or file names. More: [docs/limits.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/limits.md).
66
+
67
+ ## Documentation
68
+
69
+ | | |
70
+ | --- | --- |
71
+ | [Install](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/install.md) | Mac and Linux, commands, Grafana, troubleshooting, uninstall |
72
+ | [Connect](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/connect.md) | The proxy and the Python library for model runs; run conditions |
73
+ | [Training](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/training.md) | Following a fine-tune three ways; how "Learning?" is decided |
74
+ | [Dashboard](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/dashboard.md) | Every tile, training mode, energy and cost, battery, address options |
75
+ | [Metrics](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/metrics.md) | Every metric name, for other tools that read Prometheus |
76
+ | [Limits](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/docs/limits.md) | Privacy in full, overhead, what it cannot show |
77
+ | [AGENTS.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/AGENTS.md) | For coding agents helping you: tasks, checks, symptoms, rules |
78
+ | [CONTRIBUTING.md](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/CONTRIBUTING.md) | Tests, checks, adding a trainer, where things are |
79
+
80
+ It is [Prometheus](https://prometheus.io) with a dashboard page of its own (or
81
+ [Grafana](https://grafana.com)), [macmon](https://github.com/vladkens/macmon) for the chip on a Mac (on
82
+ Linux, a small exporter that reads `nvidia-smi`), small exporters for memory and the battery, the proxy,
83
+ and a tiny Python library, with no dependencies beyond Python itself.
84
+
85
+ ## Credits
86
+
87
+ inference-vitals runs [macmon](https://github.com/vladkens/macmon) (MIT) and
88
+ [Prometheus](https://github.com/prometheus/prometheus) (Apache-2.0), and optionally
89
+ [Grafana](https://github.com/grafana/grafana) (AGPL-3.0), installed separately (Homebrew on a Mac, your
90
+ package manager on Linux); none of them is bundled here.
91
+
92
+ ## Licence
93
+
94
+ MIT. See [LICENSE](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/LICENSE).
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "inference-vitals"
3
+ version = "0.2.0"
4
+ description = "A dashboard for running and fine-tuning AI models on an Apple Silicon Mac or Linux with NVIDIA GPUs: speed, loss, memory, heat, power and battery"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = []
10
+ keywords = [
11
+ "llm",
12
+ "inference",
13
+ "fine-tuning",
14
+ "apple-silicon",
15
+ "mlx",
16
+ "nvidia",
17
+ "prometheus",
18
+ "dashboard",
19
+ "monitoring",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Environment :: Web Environment",
24
+ "Intended Audience :: Developers",
25
+ "Intended Audience :: Science/Research",
26
+ "Operating System :: MacOS",
27
+ "Operating System :: POSIX :: Linux",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
33
+ "Topic :: System :: Monitoring",
34
+ ]
35
+
36
+ [[project.authors]]
37
+ name = "Urban Morph"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/urbanmorph/inference-vitals"
41
+ Documentation = "https://github.com/urbanmorph/inference-vitals/tree/main/docs"
42
+ Issues = "https://github.com/urbanmorph/inference-vitals/issues"
43
+ Changelog = "https://github.com/urbanmorph/inference-vitals/releases"
44
+
45
+ [project.scripts]
46
+ inference-vitals = "inference_vitals.monitor:main"
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.13.7,<0.14.0"]
50
+ build-backend = "uv_build"
51
+
52
+ [dependency-groups]
53
+ dev = [
54
+ "pytest>=9.2.0",
55
+ "ruff>=0.17.3",
56
+ ]
57
+
58
+ [tool.ruff]
59
+ line-length = 110
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "inference-vitals"
3
+ version = "0.2.0"
4
+ description = "A dashboard for running and fine-tuning AI models on an Apple Silicon Mac or Linux with NVIDIA GPUs: speed, loss, memory, heat, power and battery"
5
+ authors = [
6
+ { name = "Urban Morph" }
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11"
12
+ dependencies = []
13
+ keywords = ["llm", "inference", "fine-tuning", "apple-silicon", "mlx", "nvidia", "prometheus", "dashboard", "monitoring"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Web Environment",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: Science/Research",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
+ "Topic :: System :: Monitoring",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/urbanmorph/inference-vitals"
31
+ Documentation = "https://github.com/urbanmorph/inference-vitals/tree/main/docs"
32
+ Issues = "https://github.com/urbanmorph/inference-vitals/issues"
33
+ Changelog = "https://github.com/urbanmorph/inference-vitals/releases"
34
+
35
+ [project.scripts]
36
+ inference-vitals = "inference_vitals.monitor:main"
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.13.7,<0.14.0"]
40
+ build-backend = "uv_build"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "pytest>=9.2.0",
45
+ "ruff>=0.17.3",
46
+ ]
47
+
48
+ [tool.ruff]
49
+ line-length = 110
@@ -0,0 +1,63 @@
1
+ """What the Mac was doing when a run happened, so a slow page can be told apart from a hot or busy Mac.
2
+
3
+ Read from macOS's own commands (pmset, sysctl, sw_vers, ps), none of which need sudo. Process names are
4
+ kept, never their arguments, since arguments can hold file names.
5
+ """
6
+
7
+ import re
8
+ import subprocess
9
+ import time
10
+
11
+ MODES = {"0": "normal", "1": "low power", "2": "high power"}
12
+
13
+
14
+ def _out(*cmd) -> str:
15
+ """A command's output, or "" when the command does not exist (macOS tools on Linux)."""
16
+ try:
17
+ return subprocess.run(cmd, capture_output=True, text=True, check=False).stdout
18
+ except FileNotFoundError:
19
+ return ""
20
+
21
+
22
+ def thermal(text: str) -> dict:
23
+ def level(pattern, default):
24
+ m = re.search(pattern, text, re.IGNORECASE)
25
+ return int(m.group(1)) if m else default
26
+
27
+ return {
28
+ "thermal_warning": level(r"thermal warning level set to (\d+)", 0),
29
+ "performance_warning": level(r"performance warning level set to (\d+)", 0),
30
+ "cpu_speed_limit": level(r"CPU_Speed_Limit\s*=\s*(\d+)", 100),
31
+ }
32
+
33
+
34
+ def on_mains(batt: str) -> bool:
35
+ return "'AC Power'" in batt
36
+
37
+
38
+ def power_mode(pm: str) -> str:
39
+ m = re.search(r"\bpowermode\s+(\d)", pm)
40
+ return MODES.get(m.group(1), "unknown") if m else "unknown"
41
+
42
+
43
+ def busy_processes(ps: str, over: float = 20.0) -> list:
44
+ busy = []
45
+ for line in ps.splitlines():
46
+ m = re.match(r"\s*([\d.]+)\s+(.+)$", line)
47
+ if m and float(m.group(1)) >= over:
48
+ busy.append([m.group(2).rstrip().rsplit("/", 1)[-1], float(m.group(1))])
49
+ return busy
50
+
51
+
52
+ def snapshot() -> dict:
53
+ limit = _out("sysctl", "-n", "iogpu.wired_limit_mb").strip()
54
+ return {
55
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
56
+ "macos": _out("sw_vers", "-productVersion").strip(),
57
+ "model": _out("sysctl", "-n", "hw.model").strip(),
58
+ "on_mains": on_mains(_out("pmset", "-g", "batt")),
59
+ "power_mode": power_mode(_out("pmset", "-g")),
60
+ **thermal(_out("pmset", "-g", "therm")),
61
+ "gpu_wired_limit_mb": int(limit) if limit.isdigit() else None,
62
+ "busy_processes": busy_processes(_out("ps", "-Ao", "pcpu=,comm=", "-r")),
63
+ }
@@ -0,0 +1,196 @@
1
+ """Follow a fine-tuning run from what its trainer prints, and publish it for the dashboard. The trainer
2
+ needs no change.
3
+
4
+ inference-vitals train -- python -m mlx_vlm.train --model-path ... --data ... wrap the command
5
+ inference-vitals watch-training train.log follow a log file
6
+
7
+ Read: mlx-vlm's trainer (metric blocks such as "Training (iteration 10):" then name=value pairs, its
8
+ progress bar, and "Saved adapter weights" lines) and mlx-lm's ("Iter 10: Train loss 1.23, ..."). Lines
9
+ it does not recognise change nothing. A trainer of your own can publish directly with
10
+ inference_vitals.training instead.
11
+ """
12
+
13
+ import argparse
14
+ import codecs
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ import time
20
+
21
+ from inference_vitals import training
22
+ from inference_vitals.metrics import DEFAULT_PORT
23
+
24
+ ANSI = re.compile(r"\x1b\[[0-9;]*m")
25
+ TOTAL = re.compile(r"Starting training\.\.\., (?:iterations|iters): (\d+)")
26
+ HEADER = re.compile(r"^(Training|Validation) \(iteration (\d+)\):")
27
+ PAIR = re.compile(r"\b([a-z_]+)=(-?[\d,]*\.?\d+(?:e[-+]?\d+)?)")
28
+ BAR = re.compile(r"\|\s*(\d+)/(\d+) \[[^\]]*?loss=([\d.]+)(?:, lr=([\d.e+-]+))?")
29
+ SAVED = re.compile(r"Saved (?:final )?(?:adapter|trainable) weights") # mlx-lm; mlx-vlm 0.7
30
+ LM_TRAIN = re.compile(
31
+ r"Iter (\d+): Train loss ([\d.]+), Learning Rate ([\d.e+-]+).*?Tokens/sec ([\d.]+)"
32
+ r"(?:, Trained Tokens (\d+))?.*?Peak mem ([\d.]+) GB"
33
+ )
34
+ LM_VAL = re.compile(r"Iter (\d+): Val loss ([\d.]+)")
35
+ KEYS = { # mlx-vlm's names -> training.Training.step's
36
+ "train_loss": "loss",
37
+ "learning_rate": "learning_rate",
38
+ "tokens_per_second": "tokens_per_second",
39
+ "peak_memory": "peak_memory_gb",
40
+ "trained_tokens": "trained_tokens",
41
+ "step_time": "step_seconds", # its own timing: lines arrive in bursts, so timing them would mislead
42
+ }
43
+
44
+
45
+ class Reader:
46
+ """Turns a trainer's printed lines into calls on a training.Training run."""
47
+
48
+ def __init__(self, run: training.Training):
49
+ self.run, self.block, self.step = run, None, 0
50
+
51
+ def feed(self, text: str):
52
+ for part in ANSI.sub("", text).split("\r"):
53
+ self._part(part.strip())
54
+
55
+ def _part(self, s: str):
56
+ if not s:
57
+ return
58
+ run = self.run
59
+ if m := TOTAL.search(s):
60
+ run.total_steps = int(m.group(1))
61
+ elif m := HEADER.match(s):
62
+ self.block, self.step = m.group(1), int(m.group(2))
63
+ if self.block == "Validation":
64
+ run.validating()
65
+ elif SAVED.search(s):
66
+ run.saving()
67
+ elif m := LM_VAL.search(s):
68
+ run.validating()
69
+ run.validate(float(m.group(2)))
70
+ elif m := LM_TRAIN.search(s):
71
+ step, loss, lr, tps, tokens, mem = m.groups()
72
+ run.step(int(step), loss=float(loss), learning_rate=float(lr), tokens_per_second=float(tps),
73
+ peak_memory_gb=float(mem), trained_tokens=int(tokens) if tokens else None) # fmt: skip
74
+ elif m := BAR.search(s):
75
+ step, total, loss, lr = m.groups()
76
+ run.total_steps = run.total_steps or int(total)
77
+ run.step(int(step), loss=float(loss), learning_rate=float(lr) if lr else None)
78
+ elif self.block and (pairs := PAIR.findall(s)):
79
+ nums = {k: float(v.replace(",", "")) for k, v in pairs}
80
+ if self.block == "Validation":
81
+ if "val_loss" in nums:
82
+ run.validate(nums["val_loss"])
83
+ elif mapped := {KEYS[k]: v for k, v in nums.items() if k in KEYS}:
84
+ run.step(self.step, **mapped)
85
+
86
+
87
+ def _parts(chunks):
88
+ """The pieces of text between line ends and carriage returns, as chunks arrive."""
89
+ buf = ""
90
+ for chunk in chunks:
91
+ buf += chunk
92
+ *done, buf = re.split(r"[\r\n]", buf)
93
+ yield from done
94
+ if buf:
95
+ yield buf
96
+
97
+
98
+ def train(
99
+ cmd: list[str], name: str, port: int = DEFAULT_PORT, epochs=None, on_finish=None, linger=20.0
100
+ ) -> int:
101
+ """Run the trainer, pass its output through untouched, and publish what it reports while it runs."""
102
+ run = training.start(name, epochs=epochs, port=port)
103
+ reader = Reader(run)
104
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
105
+ env={**os.environ, "PYTHONUNBUFFERED": "1"}) # fmt: skip
106
+
107
+ decode = codecs.getincrementaldecoder("utf-8")(errors="replace").decode # a character may span two reads
108
+
109
+ def chunks():
110
+ while data := os.read(proc.stdout.fileno(), 4096):
111
+ sys.stdout.buffer.write(data) # the trainer's own bytes, untouched
112
+ sys.stdout.buffer.flush()
113
+ yield decode(data)
114
+
115
+ try:
116
+ for part in _parts(chunks()):
117
+ reader.feed(part)
118
+ return proc.wait()
119
+ except KeyboardInterrupt: # Ctrl-C: stop the trainer too, and do not wait long for the last read
120
+ proc.terminate()
121
+ proc.wait(10)
122
+ linger = min(linger, 5)
123
+ return 130
124
+ finally:
125
+ run.finish(linger)
126
+ if on_finish:
127
+ on_finish(run)
128
+
129
+
130
+ IDLE_AFTER = 300 # seconds without a new line before a watched run counts as no longer training
131
+
132
+
133
+ def watch(
134
+ path, run: training.Training, stop=lambda: False, poll=1.0, clock=time.monotonic, idle_after=IDLE_AFTER
135
+ ):
136
+ """Read a log file as it grows, until stop() says so at its end (Ctrl-C from the command line). A log
137
+ that stops growing for `idle_after` seconds marks the run idle, so it is not shown training for days."""
138
+ reader = Reader(run)
139
+ with open(path, errors="replace") as f:
140
+
141
+ def chunks():
142
+ last = clock()
143
+ while True:
144
+ text = f.read()
145
+ if text:
146
+ last = clock()
147
+ yield text
148
+ elif stop():
149
+ return
150
+ else:
151
+ if clock() - last > idle_after and run.phase != training.PHASES["idle"]:
152
+ run.idle()
153
+ time.sleep(poll)
154
+
155
+ for part in _parts(chunks()):
156
+ reader.feed(part)
157
+
158
+
159
+ def main(action: str, argv: list[str]):
160
+ p = argparse.ArgumentParser(prog=f"inference-vitals {action}", description=__doc__,
161
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
162
+ p.add_argument("--name", help="the run's name on the dashboard (default: from the command or file)")
163
+ p.add_argument("--epochs", type=int)
164
+ p.add_argument("--port", type=int, default=DEFAULT_PORT, help="where the dashboard reads numbers")
165
+ if action == "train":
166
+ own, _, cmd = _split(argv)
167
+ args = p.parse_args(own)
168
+ if not cmd:
169
+ p.error("give the training command after --")
170
+ return train(cmd, args.name or default_name(cmd), args.port, args.epochs)
171
+ p.add_argument("log", help="the trainer's log file")
172
+ args = p.parse_args(argv)
173
+ run = training.start(args.name or os.path.basename(args.log), epochs=args.epochs, port=args.port)
174
+ try:
175
+ watch(args.log, run)
176
+ except KeyboardInterrupt:
177
+ pass
178
+ finally:
179
+ run.finish()
180
+ return 0
181
+
182
+
183
+ def default_name(cmd: list[str]) -> str:
184
+ """A run's name when none is given: the module after -m, else the script, else the program."""
185
+ if "-m" in cmd[:-1]:
186
+ return cmd[cmd.index("-m") + 1]
187
+ script = next((a for a in cmd[1:] if a.endswith(".py")), None)
188
+ return os.path.basename(script or cmd[0])
189
+
190
+
191
+ def _split(argv: list[str]):
192
+ """(our options, "--", the trainer's command)."""
193
+ if "--" in argv:
194
+ i = argv.index("--")
195
+ return argv[:i], "--", argv[i + 1 :]
196
+ return argv, "", []