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
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
|