trainmeter 0.0.2__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.
Files changed (46) hide show
  1. trainmeter/__init__.py +17 -0
  2. trainmeter/__main__.py +5 -0
  3. trainmeter/agent/__init__.py +1 -0
  4. trainmeter/agent/bootstrap/sitecustomize.py +28 -0
  5. trainmeter/agent/core.py +578 -0
  6. trainmeter/agent/sender.py +68 -0
  7. trainmeter/agent/taps.py +144 -0
  8. trainmeter/agent/wrap.py +23 -0
  9. trainmeter/catalog.py +100 -0
  10. trainmeter/cli.py +382 -0
  11. trainmeter/commands.py +99 -0
  12. trainmeter/config.py +65 -0
  13. trainmeter/doctor.py +118 -0
  14. trainmeter/emit.py +64 -0
  15. trainmeter/engine.py +621 -0
  16. trainmeter/export/__init__.py +0 -0
  17. trainmeter/export/files.py +24 -0
  18. trainmeter/export/wandb.py +87 -0
  19. trainmeter/facts.py +50 -0
  20. trainmeter/flops.py +52 -0
  21. trainmeter/metrics.py +68 -0
  22. trainmeter/passport.py +327 -0
  23. trainmeter/peaks.py +92 -0
  24. trainmeter/records.py +97 -0
  25. trainmeter/replay.py +110 -0
  26. trainmeter/report.py +47 -0
  27. trainmeter/sources/__init__.py +1 -0
  28. trainmeter/sources/gpu.py +458 -0
  29. trainmeter/sources/host.py +135 -0
  30. trainmeter/supervisor/__init__.py +1 -0
  31. trainmeter/supervisor/ingest.py +86 -0
  32. trainmeter/supervisor/launcher.py +90 -0
  33. trainmeter/supervisor/live.py +133 -0
  34. trainmeter/supervisor/store.py +118 -0
  35. trainmeter/timeline.py +102 -0
  36. trainmeter/viewer.py +273 -0
  37. trainmeter/web/__init__.py +0 -0
  38. trainmeter/web/server.py +192 -0
  39. trainmeter/web/static/app.js +571 -0
  40. trainmeter/web/static/index.html +43 -0
  41. trainmeter/web/static/style.css +157 -0
  42. trainmeter-0.0.2.dist-info/METADATA +109 -0
  43. trainmeter-0.0.2.dist-info/RECORD +46 -0
  44. trainmeter-0.0.2.dist-info/WHEEL +4 -0
  45. trainmeter-0.0.2.dist-info/entry_points.txt +3 -0
  46. trainmeter-0.0.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,458 @@
1
+ """GPU sampler: NVML and GPM counters of the selected GPUs, read from outside the training process.
2
+
3
+ The hardware sits behind the `Backend` seam. `NvmlBackend` talks to `nvidia-ml-py` (the optional
4
+ `gpu` extra, imported lazily); `FakeBackend` serves scripted numbers so everything else runs
5
+ without a GPU. Nothing here initializes CUDA, allocates on a GPU or synchronizes a device: NVML
6
+ is a management library and reading it does not create a context.
7
+
8
+ Units in a payload: activity and utilization are fractions in [0, 1], memory is in bytes, power
9
+ in watts, clocks in MHz, NVLink and PCIe throughput in bytes per second.
10
+
11
+ `NvmlBackend` is verified only against fakes. It has NOT run on real hardware: the scale of the
12
+ GPM values (percent, divided by 100 here), the metric ids and the per-generation availability
13
+ are taken from the `nvidia-ml-py` 13.615.71 sources and the NVML headers, and need the
14
+ checklist in `docs/hardware-checklist.md` on a real node.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ import threading
22
+ import time
23
+ import warnings
24
+ from collections.abc import Callable, Sequence
25
+ from typing import Any, Protocol
26
+
27
+ # GPM metric ids (NVML_GPM_METRIC_*), from nvidia-ml-py.
28
+ GPM_SM_UTIL = 2
29
+ GPM_ANY_TENSOR_UTIL = 5
30
+ GPM_DRAM_BW_UTIL = 10
31
+ GPM_PCIE_TX = 20
32
+ GPM_PCIE_RX = 21
33
+ GPM_NVLINK_RX = 60
34
+ GPM_NVLINK_TX = 61
35
+
36
+ _GPM_FIELDS = {
37
+ GPM_SM_UTIL: "sm_active",
38
+ GPM_ANY_TENSOR_UTIL: "tensor_active",
39
+ GPM_DRAM_BW_UTIL: "dram_active",
40
+ GPM_PCIE_TX: "pcie_tx_bps",
41
+ GPM_PCIE_RX: "pcie_rx_bps",
42
+ GPM_NVLINK_RX: "nvlink_rx_bps",
43
+ GPM_NVLINK_TX: "nvlink_tx_bps",
44
+ }
45
+ _FRACTIONS = {"sm_active", "tensor_active", "dram_active"} # NVML reports these in percent
46
+
47
+
48
+ class Backend(Protocol):
49
+ def open(self) -> None:
50
+ """Raises `GpuUnavailable` with a reason when there is nothing to read."""
51
+
52
+ def close(self) -> None: ...
53
+
54
+ def versions(self) -> dict[str, str]:
55
+ """`driver` and `nvml` version strings, whichever are known."""
56
+
57
+ def identities(self) -> list[dict[str, Any]]:
58
+ """Per index: `name`, `uuid`, `memory_total_b`, `max_sm_clock_mhz`."""
59
+
60
+ def basic(self, index: int) -> dict[str, Any]:
61
+ """`gpu_util`, `mem_used_b`, `power_w`, `temp_c`, `sm_clock_mhz`, `mem_clock_mhz`."""
62
+
63
+ def gpm_support(self, index: int) -> str | None:
64
+ """None when GPM works on this device, else the reason it does not."""
65
+
66
+ def gpm(self, index: int) -> dict[str, float] | None:
67
+ """Counters over the window since the previous call, or None on the first call."""
68
+
69
+ def processes(self, index: int) -> set[int] | None:
70
+ """Pids of the compute processes on this GPU, or None when NVML cannot say.
71
+
72
+ Inside a container these are host pids, which do not match the container's own.
73
+ """
74
+
75
+
76
+ class GpuUnavailable(Exception):
77
+ """NVML cannot be used on this node. The message is the reason shown to the owner."""
78
+
79
+
80
+ # ---- selection ----------------------------------------------------------------------------
81
+
82
+ _INDEX_LIST = re.compile(r"\d+(,\d+)*")
83
+ _UUID_LIST = re.compile(r"(GPU|MIG)-[0-9A-Za-z-]+(,(GPU|MIG)-[0-9A-Za-z-]+)*")
84
+
85
+
86
+ def valid_gpus_spec(spec: str) -> bool:
87
+ """`0,1` or a list of GPU UUIDs, the two forms `--gpus` and `CUDA_VISIBLE_DEVICES` share."""
88
+ return bool(_INDEX_LIST.fullmatch(spec) or _UUID_LIST.fullmatch(spec))
89
+
90
+
91
+ def select(
92
+ identities: Sequence[dict[str, Any]], flag: str | None, cuda_visible: str | None
93
+ ) -> tuple[list[int], str, str | None]:
94
+ """Choose the GPUs to sample: `--gpus`, else `CUDA_VISIBLE_DEVICES`, else all.
95
+
96
+ Returns (indices, how, warning). `how` is `flag` (declared), `env` or `all` (both assumed).
97
+ An unusable `CUDA_VISIBLE_DEVICES` falls back to all GPUs with a warning; an unusable
98
+ `--gpus` selects nothing, because the owner asked for something specific.
99
+
100
+ `CUDA_VISIBLE_DEVICES` numbers devices in CUDA order, `--gpus` and NVML in PCI bus order.
101
+ They agree when `CUDA_DEVICE_ORDER=PCI_BUS_ID`; the warning says so when it is not set.
102
+ """
103
+ everything = list(range(len(identities)))
104
+ uuids = {ident.get("uuid"): i for i, ident in enumerate(identities)}
105
+
106
+ def resolve(spec: str) -> tuple[list[int], str | None]:
107
+ picked: list[int] = []
108
+ for part in spec.split(","):
109
+ part = part.strip()
110
+ index = int(part) if part.isdigit() else uuids.get(part, uuids.get(part.upper()))
111
+ if index is None or not 0 <= index < len(identities):
112
+ return [], f"{part!r} is not a GPU on this node ({len(identities)} found)"
113
+ picked.append(index)
114
+ return sorted(set(picked)), None
115
+
116
+ if flag is not None:
117
+ picked, problem = resolve(flag)
118
+ return picked, "flag", (f"--gpus: {problem}" if problem else None)
119
+ if cuda_visible is not None and cuda_visible.strip():
120
+ picked, problem = resolve(cuda_visible)
121
+ if problem:
122
+ return everything, "all", f"CUDA_VISIBLE_DEVICES: {problem}; sampling every GPU"
123
+ note = None
124
+ if os.environ.get("CUDA_DEVICE_ORDER") != "PCI_BUS_ID" and cuda_visible[0].isdigit():
125
+ note = (
126
+ "CUDA_VISIBLE_DEVICES indices are matched to NVML order; "
127
+ "set CUDA_DEVICE_ORDER=PCI_BUS_ID to be sure"
128
+ )
129
+ return picked, "env", note
130
+ return everything, "all", None
131
+
132
+
133
+ # ---- NVML ---------------------------------------------------------------------------------
134
+
135
+
136
+ class NvmlBackend:
137
+ """NVML and GPM through `nvidia-ml-py`. Not verified on real hardware, see the module doc."""
138
+
139
+ def __init__(self) -> None:
140
+ self._n: Any = None
141
+ self._handles: list[Any] = []
142
+ self._prev: dict[int, Any] = {}
143
+ self._gpm_ok: dict[int, str | None] = {}
144
+
145
+ def open(self) -> None:
146
+ try:
147
+ import pynvml # nvidia-ml-py installs the module under this name
148
+ except ImportError as exc:
149
+ raise GpuUnavailable(
150
+ "nvidia-ml-py is not installed (uv add 'trainmeter[gpu]')"
151
+ ) from exc
152
+ self._n = pynvml
153
+ try:
154
+ pynvml.nvmlInit()
155
+ count = pynvml.nvmlDeviceGetCount()
156
+ self._handles = [pynvml.nvmlDeviceGetHandleByIndex(i) for i in range(count)]
157
+ except pynvml.NVMLError as exc:
158
+ raise GpuUnavailable(f"NVML failed to initialize: {exc}") from exc
159
+ if not self._handles:
160
+ raise GpuUnavailable("NVML sees no GPUs on this node")
161
+
162
+ def close(self) -> None:
163
+ n = self._n
164
+ if n is None:
165
+ return
166
+ for sample in self._prev.values():
167
+ try:
168
+ n.nvmlGpmSampleFree(sample)
169
+ except Exception: # noqa: BLE001
170
+ pass
171
+ self._prev.clear()
172
+ try:
173
+ n.nvmlShutdown()
174
+ except Exception: # noqa: BLE001
175
+ pass
176
+ self._n = None
177
+
178
+ def versions(self) -> dict[str, str]:
179
+ n, out = self._n, {}
180
+ for key, read in (
181
+ ("driver", lambda: n.nvmlSystemGetDriverVersion()),
182
+ ("nvml", lambda: n.nvmlSystemGetNVMLVersion()),
183
+ ):
184
+ try:
185
+ out[key] = self._text(read())
186
+ except n.NVMLError:
187
+ pass
188
+ return out
189
+
190
+ @staticmethod
191
+ def _text(value: Any) -> str:
192
+ return value.decode() if isinstance(value, bytes) else str(value)
193
+
194
+ def identities(self) -> list[dict[str, Any]]:
195
+ n, out = self._n, []
196
+ for h in self._handles:
197
+ ident: dict[str, Any] = {
198
+ "name": self._text(n.nvmlDeviceGetName(h)),
199
+ "uuid": self._text(n.nvmlDeviceGetUUID(h)),
200
+ }
201
+ for key, read in (
202
+ ("memory_total_b", lambda h=h: n.nvmlDeviceGetMemoryInfo(h).total),
203
+ (
204
+ "max_sm_clock_mhz",
205
+ lambda h=h: n.nvmlDeviceGetMaxClockInfo(h, n.NVML_CLOCK_SM),
206
+ ),
207
+ ):
208
+ try:
209
+ ident[key] = read()
210
+ except n.NVMLError:
211
+ pass
212
+ out.append(ident)
213
+ return out
214
+
215
+ def basic(self, index: int) -> dict[str, Any]:
216
+ n, h = self._n, self._handles[index]
217
+ out: dict[str, Any] = {}
218
+
219
+ def put(key: str, read: Callable[[], Any]) -> None:
220
+ try:
221
+ out[key] = read()
222
+ except n.NVMLError:
223
+ pass # this counter is not offered by the device, the key is simply absent
224
+
225
+ put("gpu_util", lambda: n.nvmlDeviceGetUtilizationRates(h).gpu / 100)
226
+ put("mem_used_b", lambda: n.nvmlDeviceGetMemoryInfo(h).used)
227
+ put("power_w", lambda: n.nvmlDeviceGetPowerUsage(h) / 1000) # milliwatts
228
+ put("temp_c", lambda: n.nvmlDeviceGetTemperature(h, n.NVML_TEMPERATURE_GPU))
229
+ put("sm_clock_mhz", lambda: n.nvmlDeviceGetClockInfo(h, n.NVML_CLOCK_SM))
230
+ put("mem_clock_mhz", lambda: n.nvmlDeviceGetClockInfo(h, n.NVML_CLOCK_MEM))
231
+ return out
232
+
233
+ def gpm_support(self, index: int) -> str | None:
234
+ if index in self._gpm_ok:
235
+ return self._gpm_ok[index]
236
+ n, reason = self._n, None
237
+ try:
238
+ if not n.nvmlGpmQueryDeviceSupport(self._handles[index]).isSupportedDevice:
239
+ reason = "GPM is not supported by this GPU (it needs Hopper or newer)"
240
+ except (n.NVMLError, AttributeError) as exc:
241
+ reason = f"GPM is not available: {exc}"
242
+ self._gpm_ok[index] = reason
243
+ return reason
244
+
245
+ def processes(self, index: int) -> set[int] | None:
246
+ n = self._n
247
+ try:
248
+ return {
249
+ int(p.pid) for p in n.nvmlDeviceGetComputeRunningProcesses(self._handles[index])
250
+ }
251
+ except (n.NVMLError, AttributeError):
252
+ return None
253
+
254
+ def gpm(self, index: int) -> dict[str, float] | None:
255
+ n, h = self._n, self._handles[index]
256
+ new = n.nvmlGpmSampleAlloc()
257
+ try:
258
+ n.nvmlGpmSampleGet(h, new)
259
+ except Exception:
260
+ n.nvmlGpmSampleFree(new)
261
+ raise
262
+ old = self._prev.get(index)
263
+ self._prev[index] = new
264
+ if old is None:
265
+ return None
266
+ try:
267
+ request = n.c_nvmlGpmMetricsGet_t()
268
+ request.version = n.NVML_GPM_METRICS_GET_VERSION
269
+ request.sample1, request.sample2 = old, new
270
+ ids = list(_GPM_FIELDS)
271
+ request.numMetrics = len(ids)
272
+ for slot, metric_id in enumerate(ids):
273
+ request.metrics[slot].metricId = metric_id
274
+ n.nvmlGpmMetricsGet(request)
275
+ out: dict[str, float] = {}
276
+ for slot, metric_id in enumerate(ids):
277
+ metric = request.metrics[slot]
278
+ if metric.nvmlReturn != n.NVML_SUCCESS:
279
+ continue
280
+ field = _GPM_FIELDS[metric_id]
281
+ out[field] = metric.value / 100 if field in _FRACTIONS else metric.value
282
+ return out
283
+ finally:
284
+ n.nvmlGpmSampleFree(old)
285
+
286
+
287
+ # ---- fake ---------------------------------------------------------------------------------
288
+
289
+
290
+ class FakeBackend:
291
+ """Scripted backend for tests and for developing without a GPU.
292
+
293
+ `gpm_rows[index]` is a list of counter dicts served in order, one per `gpm` call after the
294
+ first; the last row repeats. `open_error` makes `open` raise `GpuUnavailable`.
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ identities: Sequence[dict[str, Any]] = (),
300
+ basic: dict[int, dict[str, Any]] | None = None,
301
+ gpm_rows: dict[int, list[dict[str, float]]] | None = None,
302
+ gpm_unsupported: str | None = None,
303
+ open_error: str | None = None,
304
+ versions: dict[str, str] | None = None,
305
+ processes: dict[int, set[int]] | None = None,
306
+ ) -> None:
307
+ self._versions = versions or {}
308
+ self._processes = processes
309
+ self._identities = [dict(i) for i in identities]
310
+ self._basic = basic or {}
311
+ self._rows = gpm_rows or {}
312
+ self._gpm_unsupported = gpm_unsupported
313
+ self._open_error = open_error
314
+ self._calls: dict[int, int] = {}
315
+ self.opened = self.closed = False
316
+
317
+ def open(self) -> None:
318
+ if self._open_error:
319
+ raise GpuUnavailable(self._open_error)
320
+ self.opened = True
321
+
322
+ def close(self) -> None:
323
+ self.closed = True
324
+
325
+ def versions(self) -> dict[str, str]:
326
+ return dict(self._versions)
327
+
328
+ def identities(self) -> list[dict[str, Any]]:
329
+ return [dict(i) for i in self._identities]
330
+
331
+ def basic(self, index: int) -> dict[str, Any]:
332
+ return dict(self._basic.get(index, {}))
333
+
334
+ def gpm_support(self, index: int) -> str | None:
335
+ return self._gpm_unsupported
336
+
337
+ def processes(self, index: int) -> set[int] | None:
338
+ return None if self._processes is None else set(self._processes.get(index, set()))
339
+
340
+ def gpm(self, index: int) -> dict[str, float] | None:
341
+ n = self._calls.get(index, 0)
342
+ self._calls[index] = n + 1
343
+ rows = self._rows.get(index)
344
+ if n == 0 or not rows:
345
+ return None
346
+ return dict(rows[min(n - 1, len(rows) - 1)])
347
+
348
+
349
+ def default_backend() -> Backend:
350
+ return NvmlBackend()
351
+
352
+
353
+ # ---- sampler ------------------------------------------------------------------------------
354
+
355
+
356
+ class GpuSampler:
357
+ """Calls `emit(payload)` about every `interval` seconds with one `sample.gpu` payload.
358
+
359
+ A payload is `{"gpus": [...], "selected_by": ..., "window_s": ...}` plus `gpm_unavailable`
360
+ (a reason) when the counters are missing. When NVML cannot be used at all the one payload
361
+ is `{"unavailable": reason}`. Every failure is reported once and the thread goes quiet.
362
+ """
363
+
364
+ def __init__(
365
+ self,
366
+ emit: Callable[[dict[str, Any]], None],
367
+ *,
368
+ gpus: str | None = None,
369
+ cuda_visible: str | None = None,
370
+ backend: Backend | None = None,
371
+ interval: float = 1.0,
372
+ clock: Callable[[], float] | None = None,
373
+ pids: Callable[[], set[int]] | None = None,
374
+ ) -> None:
375
+ self._pids = pids # `tm attach`: the GPUs the watched process tree is using
376
+ self._emit = emit
377
+ self._flag, self._env = gpus, cuda_visible
378
+ self._backend = backend if backend is not None else default_backend()
379
+ self._interval = interval
380
+ self._clock = clock or time.monotonic
381
+ self._stop = threading.Event()
382
+ self._thread = threading.Thread(target=self._run, name="trainmeter-gpu", daemon=True)
383
+
384
+ def start(self) -> None:
385
+ self._thread.start()
386
+
387
+ def stop(self) -> None:
388
+ self._stop.set()
389
+ if self._thread.is_alive():
390
+ self._thread.join(timeout=2.0)
391
+
392
+ def _run(self) -> None:
393
+ try:
394
+ try:
395
+ self._backend.open()
396
+ except GpuUnavailable as exc:
397
+ self._emit({"unavailable": str(exc)})
398
+ return
399
+ idents = self._backend.identities()
400
+ indices, how, warning = self._choose(idents)
401
+ if warning:
402
+ warnings.warn(f"trainmeter: {warning}", stacklevel=2)
403
+ if not indices:
404
+ self._emit({"unavailable": warning or "no GPU selected"})
405
+ return
406
+ self._loop(idents, indices, how)
407
+ except Exception as exc: # noqa: BLE001 - monitoring must not raise into anything
408
+ warnings.warn(f"trainmeter GPU sampler stopped: {exc!r}", stacklevel=2)
409
+ finally:
410
+ try:
411
+ self._backend.close()
412
+ except Exception: # noqa: BLE001
413
+ pass
414
+
415
+ def _choose(self, idents: list[dict[str, Any]]) -> tuple[list[int], str, str | None]:
416
+ """`--gpus`, else `CUDA_VISIBLE_DEVICES`, else (attach) the GPUs in use, else all."""
417
+ indices, how, warning = select(idents, self._flag, self._env)
418
+ if self._pids is None or how != "all":
419
+ return indices, how, warning
420
+ mine = self._pids()
421
+ owned = [i for i in range(len(idents)) if mine & (self._backend.processes(i) or set())]
422
+ if owned:
423
+ return owned, "process", None
424
+ return (
425
+ indices,
426
+ "all",
427
+ "no GPU lists a process of the watched program (inside a container NVML shows host "
428
+ "pids); sampling every GPU, pass --gpus to choose",
429
+ )
430
+
431
+ def _loop(self, idents: list[dict[str, Any]], indices: list[int], how: str) -> None:
432
+ last = {i: self._clock() for i in indices}
433
+ sent_versions = False
434
+ while True:
435
+ gpus: list[dict[str, Any]] = []
436
+ reasons: set[str] = set()
437
+ for i in indices:
438
+ row: dict[str, Any] = {"index": i, **idents[i], **self._backend.basic(i)}
439
+ reason = self._backend.gpm_support(i)
440
+ if reason is None:
441
+ now = self._clock()
442
+ counters = self._backend.gpm(i)
443
+ if counters is not None:
444
+ row.update(counters)
445
+ row["window_s"] = now - last[i]
446
+ last[i] = now
447
+ else:
448
+ reasons.add(reason)
449
+ gpus.append(row)
450
+ payload: dict[str, Any] = {"gpus": gpus, "selected_by": how}
451
+ if not sent_versions:
452
+ sent_versions = True
453
+ payload["versions"] = self._backend.versions()
454
+ if reasons:
455
+ payload["gpm_unavailable"] = "; ".join(sorted(reasons))
456
+ self._emit(payload)
457
+ if self._stop.wait(self._interval):
458
+ return
@@ -0,0 +1,135 @@
1
+ """Host sampler: uptime and the resource use of the child process tree, read from /proc.
2
+
3
+ Linux only. Where /proc is missing every reader returns None and the sampler stays quiet.
4
+ Nothing here attaches to or traces the child: it only reads files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import threading
11
+ import warnings
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ PROC = Path("/proc")
17
+
18
+
19
+ def read_uptime(proc_root: Path = PROC) -> float | None:
20
+ """Seconds since the host booted."""
21
+ try:
22
+ return float((Path(proc_root) / "uptime").read_text().split()[0])
23
+ except (OSError, ValueError, IndexError):
24
+ return None
25
+
26
+
27
+ def _stat(pid: int, proc_root: Path) -> tuple[int, int, int] | None:
28
+ """(ppid, utime ticks, stime ticks) of a process, or None if it is gone."""
29
+ try:
30
+ text = (proc_root / str(pid) / "stat").read_text()
31
+ # The command name is in parentheses and may contain spaces, so split after the last one.
32
+ fields = text[text.rindex(")") + 2 :].split()
33
+ return int(fields[1]), int(fields[11]), int(fields[12])
34
+ except (OSError, ValueError, IndexError):
35
+ return None
36
+
37
+
38
+ def _rss_pages(pid: int, proc_root: Path) -> int:
39
+ try:
40
+ return int((proc_root / str(pid) / "statm").read_text().split()[1])
41
+ except (OSError, ValueError, IndexError):
42
+ return 0
43
+
44
+
45
+ def descendants(root_pid: int, proc_root: Path = PROC) -> list[int]:
46
+ """`root_pid` and every process below it, or an empty list if the root is gone."""
47
+ proc_root = Path(proc_root)
48
+ parent_of: dict[int, int] = {}
49
+ try:
50
+ names = [p.name for p in proc_root.iterdir() if p.name.isdigit()]
51
+ except OSError:
52
+ return []
53
+ for name in names:
54
+ stat = _stat(int(name), proc_root)
55
+ if stat is not None:
56
+ parent_of[int(name)] = stat[0]
57
+ if root_pid not in parent_of:
58
+ return []
59
+ children: dict[int, list[int]] = {}
60
+ for pid, ppid in parent_of.items():
61
+ children.setdefault(ppid, []).append(pid)
62
+ tree, todo = [], [root_pid]
63
+ while todo:
64
+ pid = todo.pop()
65
+ tree.append(pid)
66
+ todo.extend(children.get(pid, []))
67
+ return tree
68
+
69
+
70
+ def sample(
71
+ root_pid: int,
72
+ proc_root: Path = PROC,
73
+ clk_tck: int | None = None,
74
+ page_size: int | None = None,
75
+ ) -> dict[str, Any] | None:
76
+ """One `sample.host` payload for the tree under `root_pid`, or None if it has exited.
77
+
78
+ `cpu_s` and `rss_kb` cover the processes alive now. CPU time of processes that already
79
+ exited is not included.
80
+ """
81
+ proc_root = Path(proc_root)
82
+ tree = descendants(root_pid, proc_root)
83
+ if not tree:
84
+ return None
85
+ tck = clk_tck or os.sysconf("SC_CLK_TCK")
86
+ page = page_size or os.sysconf("SC_PAGE_SIZE")
87
+ ticks = pages = 0
88
+ for pid in tree:
89
+ stat = _stat(pid, proc_root)
90
+ if stat is not None:
91
+ ticks += stat[1] + stat[2]
92
+ pages += _rss_pages(pid, proc_root)
93
+ return {
94
+ "procs": len(tree),
95
+ "cpu_s": ticks / tck,
96
+ "rss_kb": pages * page // 1024,
97
+ "uptime_s": read_uptime(proc_root),
98
+ }
99
+
100
+
101
+ class HostSampler:
102
+ """Calls `emit(payload)` for the tree under `root_pid` now and then every `interval` seconds."""
103
+
104
+ def __init__(
105
+ self,
106
+ root_pid: int,
107
+ emit: Callable[[dict[str, Any]], None],
108
+ interval: float = 1.0,
109
+ proc_root: Path = PROC,
110
+ ) -> None:
111
+ self._root = root_pid
112
+ self._emit = emit
113
+ self._interval = interval
114
+ self._proc_root = Path(proc_root)
115
+ self._stop = threading.Event()
116
+ self._thread = threading.Thread(target=self._run, name="trainmeter-host", daemon=True)
117
+
118
+ def start(self) -> None:
119
+ self._thread.start()
120
+
121
+ def stop(self) -> None:
122
+ self._stop.set()
123
+ if self._thread.is_alive():
124
+ self._thread.join(timeout=2.0)
125
+
126
+ def _run(self) -> None:
127
+ try:
128
+ while True:
129
+ payload = sample(self._root, self._proc_root)
130
+ if payload is not None:
131
+ self._emit(payload)
132
+ if self._stop.wait(self._interval):
133
+ return
134
+ except Exception as exc: # noqa: BLE001 - monitoring must not raise into anything
135
+ warnings.warn(f"trainmeter host sampler stopped: {exc!r}", stacklevel=2)
@@ -0,0 +1 @@
1
+ """The `tm` supervisor: launcher and run log."""
@@ -0,0 +1,86 @@
1
+ """Receives the agents' datagrams and feeds one consumer: the single writer of state.
2
+
3
+ The socket lives in a private directory (mode 0700). Malformed datagrams are dropped and
4
+ counted. Nothing here ever raises into the supervisor's main thread.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import queue
10
+ import shutil
11
+ import socket
12
+ import tempfile
13
+ import threading
14
+ import warnings
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from ..records import decode
20
+
21
+ _STOP = object()
22
+
23
+
24
+ class Pipeline:
25
+ """Ingest thread -> queue -> one consumer thread calling `handle(record)`."""
26
+
27
+ def __init__(self, handle: Callable[[dict[str, Any]], None]) -> None:
28
+ self._handle = handle
29
+ self._queue: queue.Queue[Any] = queue.Queue()
30
+ self._dir = Path(tempfile.mkdtemp(prefix="tm-")) # mkdtemp is 0700
31
+ self.path = str(self._dir / "events.sock")
32
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
33
+ self._sock.bind(self.path)
34
+ self._sock.settimeout(0.1)
35
+ self._stop = threading.Event()
36
+ self.dropped = 0
37
+ self._ingest = threading.Thread(target=self._receive, name="trainmeter-ingest", daemon=True)
38
+ self._consumer = threading.Thread(
39
+ target=self._consume, name="trainmeter-engine", daemon=True
40
+ )
41
+
42
+ def start(self) -> None:
43
+ self._ingest.start()
44
+ self._consumer.start()
45
+
46
+ def put(self, record: dict[str, Any]) -> None:
47
+ self._queue.put(record)
48
+
49
+ def _receive(self) -> None:
50
+ while True:
51
+ try:
52
+ data = self._sock.recv(65536)
53
+ except TimeoutError:
54
+ if self._stop.is_set():
55
+ return # stopped and idle: everything already sent has been read
56
+ continue
57
+ except OSError:
58
+ return
59
+ try:
60
+ self._queue.put(decode(data.decode("utf-8")))
61
+ except (ValueError, UnicodeDecodeError):
62
+ self.dropped += 1
63
+
64
+ def _consume(self) -> None:
65
+ warned = False
66
+ while True:
67
+ item = self._queue.get()
68
+ if item is _STOP:
69
+ return
70
+ try:
71
+ self._handle(item)
72
+ except Exception as exc: # noqa: BLE001 - one bad record must not stop the run
73
+ if not warned:
74
+ warned = True
75
+ warnings.warn(f"trainmeter engine skipped a record: {exc!r}", stacklevel=1)
76
+
77
+ def close(self) -> None:
78
+ """Drain what is already queued, then stop and remove the socket directory."""
79
+ self._stop.set()
80
+ self._ingest.join(timeout=2.0)
81
+ self._queue.put(_STOP)
82
+ self._consumer.join(timeout=5.0)
83
+ try:
84
+ self._sock.close()
85
+ finally:
86
+ shutil.rmtree(self._dir, ignore_errors=True)