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
trainmeter/emit.py ADDED
@@ -0,0 +1,64 @@
1
+ """`emit(...)`: the one optional line a training loop can add.
2
+
3
+ from trainmeter import emit
4
+ emit(train_loss=loss.item())
5
+
6
+ It sends one datagram and returns. It is a no-op outside `tm`, returns None, never raises, and
7
+ never converts a tensor that lives on an accelerator: numbers and 0-dimensional CPU tensors only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numbers
13
+ import os
14
+ import sys
15
+ from typing import Any
16
+
17
+ from .agent.sender import get_sender
18
+
19
+ # Keys that are facts about the run, not series.
20
+ FACT_KEYS = frozenset({"tokens_per_step"})
21
+
22
+ _warned: set[str] = set()
23
+
24
+
25
+ def as_number(value: Any) -> float | None:
26
+ """A float for Python and NumPy numbers and 0-dim CPU tensors, else None. Never syncs."""
27
+ if isinstance(value, bool):
28
+ return None
29
+ if isinstance(value, numbers.Real):
30
+ return float(value)
31
+ try:
32
+ device = getattr(value, "device", None)
33
+ if getattr(value, "ndim", None) == 0 and getattr(device, "type", None) == "cpu":
34
+ detach = getattr(value, "detach", None) # a loss with requires_grad warns otherwise
35
+ return float(detach() if detach is not None else value)
36
+ except Exception: # noqa: BLE001
37
+ return None
38
+ return None
39
+
40
+
41
+ def emit(**values: Any) -> None:
42
+ try:
43
+ sender = get_sender()
44
+ if sender is None:
45
+ return
46
+ rank = int(os.environ["RANK"]) if "RANK" in os.environ else 0
47
+ for key, value in values.items():
48
+ number = as_number(value)
49
+ if number is None:
50
+ if key not in _warned:
51
+ _warned.add(key)
52
+ print(
53
+ f"trainmeter: emit({key}=...) skipped, not a number on the host",
54
+ file=sys.stderr,
55
+ )
56
+ continue
57
+ if key in FACT_KEYS:
58
+ sender.send(
59
+ "fact", "emit", {"key": key, "value": number, "source": "emitted"}, rank
60
+ )
61
+ else:
62
+ sender.send("metric", "emit", {"key": key, "value": number, "via": "emit"}, rank)
63
+ except Exception: # noqa: BLE001 - invariant 1: never raise into the training loop
64
+ return
trainmeter/engine.py ADDED
@@ -0,0 +1,621 @@
1
+ """The engine: records in, snapshots out. A pure function of the records it is fed.
2
+
3
+ It holds no clock and does no I/O, so a live run and a replay of `events.jsonl` give the same
4
+ numbers. Every number that cannot be computed is absent and named in `absent`, never a zero.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from . import catalog, metrics
12
+ from .facts import FactStore, rank
13
+ from .flops import CONVENTIONS, ModelShape, all_conventions, flops_per_token
14
+ from .peaks import lookup
15
+
16
+ SHAPE_KEYS = ("n_params", "n_embedding_params", "n_layers", "d_model", "seq_len")
17
+
18
+
19
+ class Engine:
20
+ def __init__(self) -> None:
21
+ self.facts = FactStore()
22
+ self._devices: dict[tuple[Any, Any], dict[str, Any]] = {}
23
+ self._world_size = 1
24
+ self._prev_t: float | None = None
25
+ self._ema = metrics.Ema()
26
+ self._steps = 0
27
+ self._tokens_seen = 0
28
+ self._detected_tokens_per_step: float | None = None
29
+ self._series: dict[str, dict[str, Any]] = {}
30
+ self._seq = 0
31
+ # time split
32
+ self._t_start: float | None = None
33
+ self._t_end: float | None = None
34
+ self._t_last = 0.0
35
+ self._first_step_t: float | None = None
36
+ self._last_step_t: float | None = None
37
+ self._phase_s: dict[str, float] = {}
38
+ self._data_before_first = 0.0
39
+ self._probes: set[str] = set()
40
+ self.hello: dict[str, Any] = {}
41
+ self._host: dict[str, Any] = {}
42
+ self._host_prev: tuple[float, float] | None = None # (t, cpu_s) of the last sample
43
+ self._node_uptime: float | None = None
44
+ self._rank_prev: dict[int, float] = {}
45
+ self._rank_dt: dict[int, metrics.Ema] = {}
46
+ # GPU sampler state
47
+ self._gpu_devices: dict[Any, dict[str, Any]] = {}
48
+ self._gpu_selected_by: str | None = None
49
+ self._gpu_versions: dict[str, str] = {}
50
+ self._gpu_unavailable: str | None = None
51
+ self._gpm_unavailable: str | None = None
52
+ self._ladder: dict[int, dict[str, float]] = {}
53
+ self._ofu_ema = metrics.Ema(alpha=0.2, warmup=0)
54
+ self._dram_ema = metrics.Ema(alpha=0.2, warmup=0)
55
+ self._observed_flops = 0.0
56
+ self._observed_unknown: str | None = None
57
+
58
+ # ---- input -------------------------------------------------------------------------
59
+
60
+ def feed(self, record: dict[str, Any]) -> None:
61
+ kind, d = record.get("kind"), record.get("d") or {}
62
+ if isinstance(record.get("t"), (int, float)):
63
+ self._t_last = max(self._t_last, record["t"])
64
+ if kind == "run.start":
65
+ self._t_start = record["t"]
66
+ self._node_uptime = d.get("node_uptime_s")
67
+ elif kind == "run.end":
68
+ self._t_end = record["t"]
69
+ elif kind == "phase":
70
+ self._phase(record, d)
71
+ elif kind == "fact":
72
+ self._fact(record, d)
73
+ elif kind == "hello":
74
+ self._world_size = max(self._world_size, int(d.get("world_size") or 1))
75
+ if (record.get("rank") or 0) == 0:
76
+ self._probes |= set(d.get("probes") or [])
77
+ self.hello = {**d, **self.hello}
78
+ elif kind == "step":
79
+ self._step(record, d)
80
+ elif kind == "metric":
81
+ self._metric(record, d)
82
+ elif kind == "sample.gpu":
83
+ self._sample_gpu(d)
84
+ elif kind == "sample.host":
85
+ self._sample_host(record, d)
86
+
87
+ def _fact(self, record: dict[str, Any], d: dict[str, Any]) -> None:
88
+ key, source = d.get("key"), d.get("source", "detected")
89
+ if key == "device":
90
+ dev = d.get("value") or {}
91
+ ident = (record.get("rank"), dev.get("uuid") or dev.get("index"))
92
+ self._devices[ident] = dev
93
+ elif isinstance(key, str):
94
+ self.facts.offer(key, d.get("value"), source, d.get("note"))
95
+
96
+ def _tokens_in(self, d: dict[str, Any], k: int) -> float | None:
97
+ """Tokens in a step record. Detection always runs, so a declared value can be checked."""
98
+ per_rank = d.get("tokens")
99
+ if per_rank is not None:
100
+ dp = self.facts.get("dp")
101
+ dp_value = int(dp.value) if dp is not None else self._world_size
102
+ self._detected_tokens_per_step = float(per_rank) * dp_value / k
103
+ declared = self.facts.get("tokens_per_step")
104
+ if declared is not None and declared.source in ("declared", "emitted"):
105
+ return k * float(declared.value)
106
+ return None if per_rank is None else k * self._detected_tokens_per_step # type: ignore[operator]
107
+
108
+ def _rank_step_time(self, rank: int, t: float, k: int) -> None:
109
+ """Per-rank seconds per step, for straggler detection. Rank 0 also gives the step time."""
110
+ prev = self._rank_prev.get(rank)
111
+ self._rank_prev[rank] = t
112
+ if prev is not None and t > prev:
113
+ self._rank_dt.setdefault(rank, metrics.Ema(alpha=0.2, warmup=1)).update((t - prev) / k)
114
+
115
+ def _step(self, record: dict[str, Any], d: dict[str, Any]) -> None:
116
+ rank, k, t = record.get("rank") or 0, int(d.get("k") or 1), record["t"]
117
+ self._rank_step_time(rank, t, k)
118
+ if rank != 0:
119
+ return # rank 0 is the step clock
120
+ if self._first_step_t is None:
121
+ self._first_step_t = t
122
+ self._data_before_first = self._phase_s.get("data", 0.0)
123
+ self._last_step_t = t
124
+ self._steps += k
125
+ tokens = self._tokens_in(d, k)
126
+ if tokens is not None:
127
+ self._tokens_seen += tokens
128
+ if self._prev_t is not None and t > self._prev_t and tokens is not None:
129
+ self._ema.update(tokens / (t - self._prev_t))
130
+ self._prev_t = t
131
+
132
+ def _sample_host(self, record: dict[str, Any], d: dict[str, Any]) -> None:
133
+ """Process-tree use from /proc. CPU is cores busy between two samples."""
134
+ host: dict[str, Any] = {}
135
+ if isinstance(d.get("procs"), int):
136
+ host["procs"] = d["procs"]
137
+ if isinstance(d.get("rss_kb"), (int, float)):
138
+ host["rss_b"] = d["rss_kb"] * 1024
139
+ cpu, t = d.get("cpu_s"), record.get("t")
140
+ if isinstance(cpu, (int, float)) and isinstance(t, (int, float)):
141
+ prev = self._host_prev
142
+ self._host_prev = (t, cpu)
143
+ # CPU time of processes that already exited drops out, so a fall is not a rate
144
+ if prev is not None and t > prev[0] and cpu >= prev[1]:
145
+ host["cpu_cores"] = (cpu - prev[1]) / (t - prev[0])
146
+ elif "cpu_cores" in self._host:
147
+ host["cpu_cores"] = self._host["cpu_cores"]
148
+ self._host = host
149
+
150
+ def _sample_gpu(self, d: dict[str, Any]) -> None:
151
+ if "unavailable" in d:
152
+ self._gpu_unavailable = str(d["unavailable"])
153
+ return
154
+ self._gpu_selected_by = d.get("selected_by", self._gpu_selected_by)
155
+ if isinstance(d.get("versions"), dict):
156
+ self._gpu_versions = d["versions"]
157
+ self._gpm_unavailable = d.get("gpm_unavailable")
158
+ ofus: list[float] = []
159
+ drams: list[float] = []
160
+ for row in d.get("gpus") or []:
161
+ index = row.get("index")
162
+ ident = row.get("uuid") or index
163
+ self._gpu_devices[ident] = {"name": row.get("name"), "index": index}
164
+ ladder = {k: row[k] for k in ("gpu_util", "sm_active", "tensor_active") if k in row}
165
+ ofu = self._row_ofu(row)
166
+ if ofu is not None:
167
+ ladder["ofu"] = ofu
168
+ ofus.append(ofu)
169
+ self._observe(row, ofu)
170
+ if "dram_active" in row:
171
+ drams.append(row["dram_active"])
172
+ self._ladder[index] = ladder
173
+ if ofus:
174
+ self._ofu_ema.update(sum(ofus) / len(ofus))
175
+ if drams:
176
+ self._dram_ema.update(sum(drams) / len(drams))
177
+
178
+ @staticmethod
179
+ def _row_ofu(row: dict[str, Any]) -> float | None:
180
+ need = ("tensor_active", "sm_clock_mhz", "max_sm_clock_mhz")
181
+ if (
182
+ not all(isinstance(row.get(k), (int, float)) for k in need)
183
+ or not row["max_sm_clock_mhz"]
184
+ ):
185
+ return None
186
+ return metrics.ofu(row["tensor_active"], row["sm_clock_mhz"], row["max_sm_clock_mhz"])
187
+
188
+ def _observe(self, row: dict[str, Any], ofu: float) -> None:
189
+ """Observed FLOPs: this GPU's OFU x its own dense peak x the window it was measured over."""
190
+ window = row.get("window_s")
191
+ peak = lookup(row["name"]) if row.get("name") else None
192
+ if peak is None:
193
+ self._observed_unknown = f"{row.get('name') or 'a GPU'} has no dense peak in the table"
194
+ elif window and window > 0:
195
+ self._observed_flops += ofu * peak.bf16_dense_flops * window
196
+
197
+ def _phase(self, record: dict[str, Any], d: dict[str, Any]) -> None:
198
+ """Seconds spent in a named phase. Rank 0 is the clock, like for steps."""
199
+ name, seconds = d.get("name"), d.get("s")
200
+ if (record.get("rank") or 0) == 0 and isinstance(name, str):
201
+ if isinstance(seconds, (int, float)) and seconds >= 0:
202
+ self._phase_s[name] = self._phase_s.get(name, 0.0) + float(seconds)
203
+
204
+ def _metric(self, record: dict[str, Any], d: dict[str, Any]) -> None:
205
+ key, value = d.get("key"), d.get("value")
206
+ if isinstance(key, str) and isinstance(value, (int, float)):
207
+ via = d.get("via")
208
+ emitted = via == "emit" if via else record.get("src") == "emit"
209
+ self._seq += 1
210
+ count = self._series[key]["count"] if key in self._series else 0
211
+ self._series[key] = {
212
+ "value": float(value),
213
+ "count": count + 1,
214
+ "source": "emitted" if emitted else "tapped",
215
+ "via": via or ("emit" if emitted else None),
216
+ "seq": self._seq,
217
+ }
218
+
219
+ # ---- output ------------------------------------------------------------------------
220
+
221
+ def position(self) -> dict[str, Any]:
222
+ """Where the run is now, the x values of every chart. Cheap: no snapshot is built."""
223
+ fpt = self._flops_per_token()
224
+ value = fpt["value"] if fpt else None
225
+ return {
226
+ "step": self._steps,
227
+ "tokens": self._tokens_seen,
228
+ "flops": None if value is None else value * self._tokens_seen,
229
+ }
230
+
231
+ def _visible(self) -> dict[Any, dict[str, Any]]:
232
+ """The devices the training process reported, else the ones the sampler was watching."""
233
+ return self._devices or self._gpu_devices
234
+
235
+ def _devices_source(self) -> str:
236
+ if self._devices:
237
+ return "detected"
238
+ if self._gpu_selected_by == "process":
239
+ return "detected" # the GPUs NVML lists for the watched process
240
+ return "declared" if self._gpu_selected_by == "flag" else "assumed"
241
+
242
+ def _n_gpus(self) -> int | None:
243
+ return len(self._visible()) or None
244
+
245
+ def _peak(self):
246
+ names = {dev.get("name") for dev in self._visible().values() if dev.get("name")}
247
+ return lookup(next(iter(names))) if len(names) == 1 else None
248
+
249
+ def _flops_per_token(self) -> dict[str, Any] | None:
250
+ declared = self.facts.get("flops_per_token")
251
+ conv = self.facts.get("convention")
252
+ if declared is not None:
253
+ label = conv.value if conv is not None else "declared value"
254
+ return {"value": float(declared.value), "convention": label, "source": declared.source}
255
+ parts = [self.facts.get(k) for k in SHAPE_KEYS]
256
+ if any(p is None for p in parts):
257
+ return None
258
+ shape = ModelShape(*(int(p.value) for p in parts)) # type: ignore[union-attr]
259
+ source = max((p.source for p in parts), key=rank) # type: ignore[union-attr]
260
+ if conv is not None and conv.value in CONVENTIONS:
261
+ return {
262
+ "value": flops_per_token(shape, conv.value),
263
+ "convention": conv.value,
264
+ "source": source,
265
+ }
266
+ values = all_conventions(shape)
267
+ return {
268
+ "value": None,
269
+ "range": (min(values.values()), max(values.values())),
270
+ "convention": "not chosen",
271
+ "source": source,
272
+ }
273
+
274
+ def _series_snapshot(self) -> dict[str, Any]:
275
+ """One entry per canonical name; for two keys, emitted beats tapped, then the latest."""
276
+ out: dict[str, dict[str, Any]] = {}
277
+ explicit_train = any(catalog.canonical(k) == "train_loss" for k in self._series)
278
+ for key, entry in self._series.items():
279
+ name = catalog.canonical(key)
280
+ if name is None and key == catalog.BARE_LOSS and not explicit_train:
281
+ name = "train_loss"
282
+ shown = {
283
+ "value": entry["value"],
284
+ "key": key,
285
+ "count": entry["count"],
286
+ "mapped_by_name": name is not None and name != key,
287
+ "source": entry["source"],
288
+ "via": entry["via"],
289
+ }
290
+ better = (rank(entry["source"]), -entry["seq"])
291
+ old = out.get(name or key)
292
+ if old is None or better < old["_order"]:
293
+ out[name or key] = {**shown, "_order": better}
294
+ return {k: {f: v for f, v in e.items() if f != "_order"} for k, e in out.items()}
295
+
296
+ def snapshot(self, now: float | None = None) -> dict[str, Any]:
297
+ absent: dict[str, str] = {}
298
+ warnings: list[str] = []
299
+ snap: dict[str, Any] = {"steps": self._steps, "tokens_seen": self._tokens_seen}
300
+ tps = self._ema.value
301
+ if tps:
302
+ snap["tokens_per_s"] = tps
303
+ step_time = self._rank_dt.get(0)
304
+ if step_time is not None and step_time.value:
305
+ snap["step_time_s"] = step_time.value
306
+ self._ranks(snap)
307
+ fpt = self._flops_per_token()
308
+ peak, n_gpus = self._peak(), self._n_gpus()
309
+ if fpt is not None:
310
+ snap["flops_per_token"] = fpt
311
+ if fpt["value"] is not None:
312
+ snap["flops_spent"] = fpt["value"] * self._tokens_seen
313
+ else:
314
+ absent["flops_per_token"] = (
315
+ "declare --params, --layers, --d-model and --seq-len, or --flops-per-token"
316
+ )
317
+ self._budget(snap, fpt, tps, absent)
318
+ self._mfu(snap, fpt, tps, peak, n_gpus, absent)
319
+ self._counters(snap, peak, absent)
320
+ if peak is not None:
321
+ snap["peak"] = {
322
+ "name": peak.name,
323
+ "bf16_dense_flops": peak.bf16_dense_flops,
324
+ "hbm_bytes_per_s": peak.hbm_bytes_per_s,
325
+ "ridge": peak.ridge,
326
+ "source": peak.source,
327
+ }
328
+ if self._visible():
329
+ snap["devices"] = [
330
+ {"name": dev.get("name"), "index": dev.get("index")}
331
+ for dev in self._visible().values()
332
+ ]
333
+ if self._gpu_versions:
334
+ snap["gpu_versions"] = self._gpu_versions
335
+ shape = self._shape()
336
+ if shape is not None:
337
+ snap["conventions"] = all_conventions(shape)
338
+ if n_gpus is not None:
339
+ source = self._devices_source()
340
+ snap["n_gpus"] = {"value": n_gpus, "source": source}
341
+ if source == "assumed":
342
+ warnings.append(
343
+ "the training process did not report its GPUs, so the GPU count and model "
344
+ "are the visible devices"
345
+ )
346
+ if self._host:
347
+ snap["host"] = dict(self._host)
348
+ snap["series"] = self._series_snapshot()
349
+ self._time(snap, now, absent)
350
+ self._counted(snap, peak, n_gpus, tps, absent)
351
+ self._plan(snap, peak, n_gpus, fpt, absent)
352
+ if self.facts.get("moe_experts") is not None:
353
+ warnings.append(
354
+ f"the model config lists {self.facts.value('moe_experts')} experts: FLOPs per "
355
+ "token from total parameters overstate a mixture-of-experts model, declare the "
356
+ "active parameters with --params"
357
+ )
358
+ declared = self.facts.get("tokens_per_step")
359
+ det = self._detected_tokens_per_step
360
+ if declared is not None and det and abs(det - float(declared.value)) > 0.01 * det:
361
+ warnings.append(
362
+ f"declared tokens per step {float(declared.value):g} disagrees with "
363
+ f"detected {det:g}; the declared value is used"
364
+ )
365
+ if self.facts.get("observe_only") is not None:
366
+ warnings.append(
367
+ "attached from outside: there is no agent in the program, so steps, tokens, loss "
368
+ "and MFU are absent; only GPU and host counters are shown"
369
+ )
370
+ snap["warnings"] = warnings
371
+ snap["absent"] = absent
372
+ return snap
373
+
374
+ def _counters(self, snap, peak, absent) -> None:
375
+ """OFU, observed FLOPs, arithmetic intensity, the gap and the ladder: all from counters."""
376
+ reason = self._gpu_unavailable or self._gpm_unavailable
377
+ if self._gpu_unavailable is None and self._ladder:
378
+ snap["ladder"] = {
379
+ "gpus": {str(i): dict(v) for i, v in sorted(self._ladder.items())},
380
+ "mean": self._ladder_mean(),
381
+ "mfu": snap.get("mfu", {}).get("value"),
382
+ }
383
+ ofu = self._ofu_ema.value
384
+ if ofu is None:
385
+ absent["ofu"] = reason or "no GPM sample yet"
386
+ absent["observed_flops"] = absent["ofu"]
387
+ else:
388
+ snap["ofu"] = {"value": ofu, "convention": "counters", "source": "measured"}
389
+ if self._observed_unknown is not None:
390
+ absent["observed_flops"] = self._observed_unknown
391
+ else:
392
+ snap["flops_observed"] = {
393
+ "value": self._observed_flops,
394
+ "convention": "counters",
395
+ "note": "approximate; includes recompute and padding",
396
+ }
397
+ mfu = snap.get("mfu", {})
398
+ if "value" in mfu:
399
+ snap["mfu_ofu_gap"] = {
400
+ "value": ofu - mfu["value"],
401
+ "ofu": "counters",
402
+ "mfu": mfu["convention"],
403
+ }
404
+ self._intensity(snap, peak, absent)
405
+
406
+ def _ladder_mean(self) -> dict[str, float]:
407
+ out: dict[str, float] = {}
408
+ for rung in ("gpu_util", "sm_active", "tensor_active", "ofu"):
409
+ values = [v[rung] for v in self._ladder.values() if rung in v]
410
+ if values:
411
+ out[rung] = sum(values) / len(values)
412
+ return out
413
+
414
+ def _intensity(self, snap, peak, absent) -> None:
415
+ mfu, dram = snap.get("mfu", {}), self._dram_ema.value
416
+ if "value" not in mfu:
417
+ absent["arithmetic_intensity"] = "needs MFU under a chosen convention"
418
+ elif peak is None or dram is None:
419
+ absent["arithmetic_intensity"] = (
420
+ self._gpu_unavailable or self._gpm_unavailable or "needs DRAM activity and a peak"
421
+ )
422
+ elif dram <= 0:
423
+ absent["arithmetic_intensity"] = "no DRAM traffic in the window"
424
+ else:
425
+ snap["arithmetic_intensity"] = {
426
+ "value": metrics.step_intensity(mfu["value"], dram, peak.ridge),
427
+ "unit": "FLOP/byte",
428
+ "note": "aggregate over the window, not per kernel",
429
+ "convention": mfu["convention"],
430
+ }
431
+
432
+ # (phase, probe that measures it, label)
433
+ _MEASURED = (
434
+ ("data", "dataloader", "Data wait"),
435
+ ("checkpoint", "checkpoint", "Checkpoints"),
436
+ ("eval", "eval", "Eval"),
437
+ )
438
+
439
+ def _time(self, snap, now, absent) -> None:
440
+ """Where the wall time went. Measured parts are named; the rest is the remainder.
441
+
442
+ elapsed = startup + data + checkpoint + eval + compute + after, where startup runs to the
443
+ first step, and compute is what is left of the training window after the measured parts.
444
+ A part whose probe never attached is absent, and its time stays in compute.
445
+ """
446
+ if self._t_start is None:
447
+ absent["time"] = "no run start record"
448
+ return
449
+ end = self._t_end if self._t_end is not None else max(now or 0.0, self._t_last)
450
+ elapsed = max(end - self._t_start, 0.0)
451
+ time_split: dict[str, Any] = {"elapsed_s": elapsed, "finished": self._t_end is not None}
452
+ if self._node_uptime is not None:
453
+ time_split["node_uptime_s"] = self._node_uptime
454
+ first, last = self._first_step_t, self._last_step_t
455
+ if first is None:
456
+ absent["time_split"] = "needs the agent's steps: no optimizer step has been seen"
457
+ snap["time"] = time_split
458
+ return
459
+ window_end = last if self._t_end is not None else end
460
+ window = max(window_end - first, 0.0)
461
+ measured = {
462
+ name: self._phase_s.get(name, 0.0)
463
+ for name, probe, _ in self._MEASURED
464
+ if probe in self._probes
465
+ }
466
+ in_window = {k: v for k, v in measured.items() if k != "data"}
467
+ data_rest = max(measured.get("data", 0.0) - self._data_before_first, 0.0)
468
+ startup = max(
469
+ first - self._t_start - min(self._data_before_first, first - self._t_start), 0.0
470
+ )
471
+ compute = max(window - data_rest - sum(in_window.values()), 0.0)
472
+ parts = [
473
+ {"name": "startup", "label": "Startup and compile", "s": startup, "measured": True}
474
+ ]
475
+ for name, _probe, label in self._MEASURED:
476
+ parts.append(
477
+ {
478
+ "name": name,
479
+ "label": label,
480
+ "s": measured.get(name),
481
+ "measured": name in measured,
482
+ }
483
+ )
484
+ unmeasured = [p["label"] for p in parts if not p["measured"]]
485
+ parts.append(
486
+ {
487
+ "name": "compute",
488
+ "label": "Compute and the rest" if unmeasured else "Compute",
489
+ "s": compute,
490
+ "measured": False,
491
+ }
492
+ )
493
+ if self._t_end is not None:
494
+ parts.append(
495
+ {
496
+ "name": "after",
497
+ "label": "After the last step",
498
+ "s": max(end - window_end, 0.0),
499
+ "measured": True,
500
+ }
501
+ )
502
+ time_split["parts"] = parts
503
+ time_split["remainder_includes"] = unmeasured
504
+ if elapsed > 0:
505
+ time_split["goodput"] = (compute + data_rest) / elapsed
506
+ snap["time"] = time_split
507
+
508
+ def _counted(self, snap, peak, n_gpus, tps, absent) -> None:
509
+ """The `counted` convention: executed FLOPs, shown beside MFU and never in its place."""
510
+ fact = self.facts.get("counted_flops_per_token")
511
+ if fact is None:
512
+ absent["counted"] = (
513
+ "no sampled step was counted (agent flops probe, or FlopCounterMode saw nothing)"
514
+ )
515
+ return
516
+ per_token = float(fact.value)
517
+ entry: dict[str, Any] = {
518
+ "per_token": per_token,
519
+ "convention": "counted",
520
+ "source": fact.source,
521
+ "note": "executed FLOPs from operator shapes: includes recompute, not model FLOPs",
522
+ "flops_spent": per_token * self._tokens_seen,
523
+ }
524
+ if peak is not None and n_gpus and tps:
525
+ entry["utilization"] = metrics.mfu(per_token, tps, n_gpus, peak.bf16_dense_flops)
526
+ shape = self._shape()
527
+ if shape is not None:
528
+ entry["against_conventions"] = {
529
+ name: per_token / value for name, value in all_conventions(shape).items()
530
+ }
531
+ snap["counted"] = entry
532
+
533
+ def _shape(self) -> ModelShape | None:
534
+ parts = [self.facts.get(k) for k in SHAPE_KEYS]
535
+ if any(p is None for p in parts):
536
+ return None
537
+ return ModelShape(*(int(p.value) for p in parts)) # type: ignore[union-attr]
538
+
539
+ def _plan(self, snap, peak, n_gpus, fpt, absent) -> None:
540
+ """What was declared next to what was measured."""
541
+ plan_mfu, plan_hours = self.facts.value("plan_mfu"), self.facts.value("plan_hours")
542
+ if plan_mfu is None and plan_hours is None:
543
+ absent["plan"] = "declare --plan-mfu and --plan-hours to compare the plan with the run"
544
+ return
545
+ plan: dict[str, Any] = {}
546
+ mfu = snap.get("mfu") or {}
547
+ if plan_mfu is not None:
548
+ plan["mfu"] = {
549
+ "planned": plan_mfu,
550
+ "measured": mfu.get("value"),
551
+ "convention": mfu.get("convention"),
552
+ }
553
+ if peak is not None and n_gpus and fpt and fpt["value"]:
554
+ plan["tokens_per_s"] = {
555
+ "planned": plan_mfu * n_gpus * peak.bf16_dense_flops / fpt["value"],
556
+ "measured": snap.get("tokens_per_s"),
557
+ }
558
+ if plan_hours is not None:
559
+ elapsed = (snap.get("time") or {}).get("elapsed_s")
560
+ eta = snap.get("eta_s")
561
+ plan["hours"] = {
562
+ "planned": plan_hours,
563
+ "projected": (elapsed + eta) / 3600
564
+ if elapsed is not None and eta is not None
565
+ else None,
566
+ }
567
+ if "budget_tokens" in snap:
568
+ plan["budget_tokens"] = snap["budget_tokens"]
569
+ snap["plan"] = plan
570
+
571
+ def _ranks(self, snap) -> None:
572
+ times = {r: e.value for r, e in self._rank_dt.items() if e.value}
573
+ if len(times) > 1:
574
+ slow, fast = max(times.values()), min(times.values())
575
+ snap["ranks"] = {
576
+ "step_time_s": {str(r): v for r, v in sorted(times.items())},
577
+ "slowest": str(max(times, key=times.get)),
578
+ "spread": slow / fast - 1,
579
+ }
580
+
581
+ def _budget(self, snap, fpt, tps, absent) -> None:
582
+ tokens_budget = self.facts.value("budget_tokens")
583
+ if tokens_budget is None and self.facts.get("budget_steps") is not None:
584
+ per_step = self.facts.value("tokens_per_step") or self._detected_tokens_per_step
585
+ if per_step:
586
+ tokens_budget = self.facts.value("budget_steps") * per_step
587
+ flops_budget = self.facts.value("budget_flops")
588
+ value = fpt["value"] if fpt else None
589
+ if tokens_budget is None and flops_budget is not None and value:
590
+ tokens_budget = flops_budget / value
591
+ if tokens_budget is None:
592
+ absent["progress"] = "declare --budget-tokens, --budget-steps or --budget-flops"
593
+ return
594
+ snap["budget_tokens"] = tokens_budget
595
+ snap["progress"] = self._tokens_seen / tokens_budget
596
+ if tps:
597
+ snap["eta_s"] = metrics.eta_seconds(tokens_budget - self._tokens_seen, tps)
598
+ if value:
599
+ snap["flops_required"] = value * tokens_budget
600
+ snap["flops_remaining"] = max(value * (tokens_budget - self._tokens_seen), 0.0)
601
+
602
+ def _mfu(self, snap, fpt, tps, peak, n_gpus, absent) -> None:
603
+ if fpt is None or not tps:
604
+ return
605
+ if peak is None or n_gpus is None:
606
+ absent["mfu"] = "device is unknown, so there is no dense peak to divide by"
607
+ return
608
+ entry: dict[str, Any] = {
609
+ "convention": fpt["convention"],
610
+ "peak": peak.name,
611
+ "peak_source": peak.source,
612
+ }
613
+ if fpt["value"] is not None:
614
+ entry["value"] = metrics.mfu(fpt["value"], tps, n_gpus, peak.bf16_dense_flops)
615
+ else:
616
+ lo, hi = fpt["range"]
617
+ entry["range"] = (
618
+ metrics.mfu(lo, tps, n_gpus, peak.bf16_dense_flops),
619
+ metrics.mfu(hi, tps, n_gpus, peak.bf16_dense_flops),
620
+ )
621
+ snap["mfu"] = entry
File without changes