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.
- trainmeter/__init__.py +17 -0
- trainmeter/__main__.py +5 -0
- trainmeter/agent/__init__.py +1 -0
- trainmeter/agent/bootstrap/sitecustomize.py +28 -0
- trainmeter/agent/core.py +578 -0
- trainmeter/agent/sender.py +68 -0
- trainmeter/agent/taps.py +144 -0
- trainmeter/agent/wrap.py +23 -0
- trainmeter/catalog.py +100 -0
- trainmeter/cli.py +382 -0
- trainmeter/commands.py +99 -0
- trainmeter/config.py +65 -0
- trainmeter/doctor.py +118 -0
- trainmeter/emit.py +64 -0
- trainmeter/engine.py +621 -0
- trainmeter/export/__init__.py +0 -0
- trainmeter/export/files.py +24 -0
- trainmeter/export/wandb.py +87 -0
- trainmeter/facts.py +50 -0
- trainmeter/flops.py +52 -0
- trainmeter/metrics.py +68 -0
- trainmeter/passport.py +327 -0
- trainmeter/peaks.py +92 -0
- trainmeter/records.py +97 -0
- trainmeter/replay.py +110 -0
- trainmeter/report.py +47 -0
- trainmeter/sources/__init__.py +1 -0
- trainmeter/sources/gpu.py +458 -0
- trainmeter/sources/host.py +135 -0
- trainmeter/supervisor/__init__.py +1 -0
- trainmeter/supervisor/ingest.py +86 -0
- trainmeter/supervisor/launcher.py +90 -0
- trainmeter/supervisor/live.py +133 -0
- trainmeter/supervisor/store.py +118 -0
- trainmeter/timeline.py +102 -0
- trainmeter/viewer.py +273 -0
- trainmeter/web/__init__.py +0 -0
- trainmeter/web/server.py +192 -0
- trainmeter/web/static/app.js +571 -0
- trainmeter/web/static/index.html +43 -0
- trainmeter/web/static/style.css +157 -0
- trainmeter-0.0.2.dist-info/METADATA +109 -0
- trainmeter-0.0.2.dist-info/RECORD +46 -0
- trainmeter-0.0.2.dist-info/WHEEL +4 -0
- trainmeter-0.0.2.dist-info/entry_points.txt +3 -0
- 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)
|