inference-vitals 0.2.0__py3-none-any.whl

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.
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, "", []
@@ -0,0 +1,147 @@
1
+ """NVIDIA GPUs as Prometheus numbers on 127.0.0.1:9091 (started by inference-vitals on Linux), read from
2
+ `nvidia-smi`, which comes with every NVIDIA driver. No sudo, nothing else to install.
3
+
4
+ Each GPU gets its own labels (gpu="0", name="NVIDIA GeForce RTX 4090"). A value a GPU does not report
5
+ ("[N/A]", common for power and fan on laptops and some server cards) is left out, not sent as zero.
6
+
7
+ Slowdown state, the GPU's own reasons for running below full clock speed, worst first:
8
+ 0 none · 1 power cap (normal under full load) · 2 thermal slowdown · 3 hardware slowdown (power brake,
9
+ external signal). Newer drivers call these `clocks_event_reasons`, older ones `clocks_throttle_reasons`;
10
+ both are tried.
11
+
12
+ python -m inference_vitals.gpustat --port 9091
13
+ """
14
+
15
+ import argparse
16
+ import shutil
17
+ import subprocess
18
+
19
+ from inference_vitals.metrics import labels as label_text
20
+
21
+ FIELDS = [
22
+ "index",
23
+ "name",
24
+ "utilization.gpu",
25
+ "memory.total",
26
+ "memory.used",
27
+ "memory.free",
28
+ "temperature.gpu",
29
+ "power.draw",
30
+ "power.limit",
31
+ "clocks.gr",
32
+ "clocks.mem",
33
+ "fan.speed",
34
+ ]
35
+ MIB = 2**20
36
+ # field -> (metric, multiplier)
37
+ NUMBERS = {
38
+ "utilization.gpu": ("vitals_gpu_busy_ratio", 0.01),
39
+ "memory.total": ("vitals_gpu_memory_total_bytes", MIB),
40
+ "memory.used": ("vitals_gpu_memory_used_bytes", MIB),
41
+ "memory.free": ("vitals_gpu_memory_free_bytes", MIB),
42
+ "temperature.gpu": ("vitals_gpu_temp_celsius", 1),
43
+ "power.draw": ("vitals_gpu_power_watts", 1),
44
+ "power.limit": ("vitals_gpu_power_limit_watts", 1),
45
+ "clocks.gr": ("vitals_gpu_freq_mhz", 1),
46
+ "clocks.mem": ("vitals_gpu_memory_freq_mhz", 1),
47
+ "fan.speed": ("vitals_gpu_fan_ratio", 0.01),
48
+ }
49
+ REASONS = [
50
+ "sw_power_cap",
51
+ "sw_thermal_slowdown",
52
+ "hw_thermal_slowdown",
53
+ "hw_slowdown",
54
+ "hw_power_brake_slowdown",
55
+ ]
56
+ REASON_PREFIXES = ["clocks_event_reasons", "clocks_throttle_reasons"] # newer drivers first
57
+ METRICS = [m for m, _ in NUMBERS.values()] + ["vitals_gpu_slowdown_state"]
58
+
59
+
60
+ def slowdown_state(active: dict) -> int:
61
+ if active.get("hw_slowdown") or active.get("hw_power_brake_slowdown"):
62
+ return 3
63
+ if active.get("sw_thermal_slowdown") or active.get("hw_thermal_slowdown"):
64
+ return 2
65
+ return 1 if active.get("sw_power_cap") else 0
66
+
67
+
68
+ def _rows(text: str) -> list[list[str]]:
69
+ return [[c.strip() for c in line.split(",")] for line in text.splitlines() if line.strip()]
70
+
71
+
72
+ def _number(s: str):
73
+ try:
74
+ return float(s)
75
+ except ValueError: # "[N/A]", "[Not Supported]"
76
+ return None
77
+
78
+
79
+ def _run(args):
80
+ p = subprocess.run(args, capture_output=True, text=True, check=False, timeout=10)
81
+ return p.returncode, p.stdout, p.stderr
82
+
83
+
84
+ class NvidiaGpus:
85
+ def __init__(self, run=_run, smi="nvidia-smi"):
86
+ self.run, self.smi = run, smi
87
+ self.reasons_prefix = None # found on first use; False once neither name works
88
+
89
+ def _query(self, fields):
90
+ code, out, _ = self.run(
91
+ [self.smi, f"--query-gpu={','.join(fields)}", "--format=csv,noheader,nounits"]
92
+ )
93
+ return _rows(out) if code == 0 else None
94
+
95
+ def _reasons(self) -> dict:
96
+ """gpu index -> {reason: active}; {} if this driver reports none."""
97
+ prefixes = [self.reasons_prefix] if self.reasons_prefix else REASON_PREFIXES
98
+ if self.reasons_prefix is False:
99
+ return {}
100
+ for prefix in prefixes:
101
+ rows = self._query(["index"] + [f"{prefix}.{r}" for r in REASONS])
102
+ if rows is not None:
103
+ self.reasons_prefix = prefix
104
+ return {row[0]: {r: v == "Active" for r, v in zip(REASONS, row[1:])} for row in rows}
105
+ self.reasons_prefix = False
106
+ return {}
107
+
108
+ def values(self) -> list[tuple[str, dict, float]]:
109
+ rows = self._query(FIELDS)
110
+ if not rows:
111
+ return []
112
+ reasons = self._reasons()
113
+ out = []
114
+ for row in rows:
115
+ got = dict(zip(FIELDS, row))
116
+ labels = {"gpu": got["index"], "name": got["name"]}
117
+ for field, (metric, scale) in NUMBERS.items():
118
+ n = _number(got.get(field, ""))
119
+ if n is not None:
120
+ out.append((metric, labels, round(n * scale, 4) if scale != MIB else int(n * scale)))
121
+ if got["index"] in reasons:
122
+ out.append(("vitals_gpu_slowdown_state", labels, slowdown_state(reasons[got["index"]])))
123
+ return out
124
+
125
+ def render(self) -> str:
126
+ return "".join(
127
+ f"{metric}{label_text({'gpu': lab['gpu'], 'name': lab['name']})} {value}\n"
128
+ for metric, lab, value in self.values()
129
+ )
130
+
131
+ def snapshot(self) -> str:
132
+ import json
133
+
134
+ return json.dumps([{"metric": m, **lab, "value": v} for m, lab, v in self.values()])
135
+
136
+
137
+ def main():
138
+ from inference_vitals.metrics import run_until_interrupt, serve
139
+
140
+ p = argparse.ArgumentParser()
141
+ p.add_argument("--port", type=int, default=9091)
142
+ server = serve(NvidiaGpus(smi=shutil.which("nvidia-smi") or "nvidia-smi"), p.parse_args().port)
143
+ run_until_interrupt(server)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()