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.
- inference_vitals-0.2.0/LICENSE +21 -0
- inference_vitals-0.2.0/PKG-INFO +121 -0
- inference_vitals-0.2.0/README.md +94 -0
- inference_vitals-0.2.0/pyproject.toml +59 -0
- inference_vitals-0.2.0/pyproject.toml.orig +49 -0
- inference_vitals-0.2.0/src/inference_vitals/__init__.py +0 -0
- inference_vitals-0.2.0/src/inference_vitals/conditions.py +63 -0
- inference_vitals-0.2.0/src/inference_vitals/follow.py +196 -0
- inference_vitals-0.2.0/src/inference_vitals/gpustat.py +147 -0
- inference_vitals-0.2.0/src/inference_vitals/grafana.py +532 -0
- inference_vitals-0.2.0/src/inference_vitals/metrics.py +282 -0
- inference_vitals-0.2.0/src/inference_vitals/monitor.py +355 -0
- inference_vitals-0.2.0/src/inference_vitals/page/favicon.svg +1 -0
- inference_vitals-0.2.0/src/inference_vitals/page/index.html +61 -0
- inference_vitals-0.2.0/src/inference_vitals/page/page.css +112 -0
- inference_vitals-0.2.0/src/inference_vitals/page/page.js +581 -0
- inference_vitals-0.2.0/src/inference_vitals/proxy.py +288 -0
- inference_vitals-0.2.0/src/inference_vitals/queries.py +106 -0
- inference_vitals-0.2.0/src/inference_vitals/sysstat.py +284 -0
- inference_vitals-0.2.0/src/inference_vitals/training.py +164 -0
- inference_vitals-0.2.0/src/inference_vitals/ui.py +361 -0
|
@@ -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
|
+
[](https://pypi.org/project/inference-vitals/)
|
|
36
|
+
[](https://pypi.org/project/inference-vitals/)
|
|
37
|
+
[](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/LICENSE)
|
|
38
|
+
[](https://github.com/urbanmorph/inference-vitals/actions/workflows/test.yml)
|
|
39
|
+
[](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
|
+

|
|
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
|
+
[](https://pypi.org/project/inference-vitals/)
|
|
9
|
+
[](https://pypi.org/project/inference-vitals/)
|
|
10
|
+
[](https://github.com/urbanmorph/inference-vitals/blob/v0.2.0/LICENSE)
|
|
11
|
+
[](https://github.com/urbanmorph/inference-vitals/actions/workflows/test.yml)
|
|
12
|
+
[](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
|
+

|
|
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
|
|
File without changes
|
|
@@ -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, "", []
|