trainmeter 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. trainmeter/__init__.py +17 -0
  2. trainmeter/__main__.py +5 -0
  3. trainmeter/agent/__init__.py +1 -0
  4. trainmeter/agent/bootstrap/sitecustomize.py +28 -0
  5. trainmeter/agent/core.py +578 -0
  6. trainmeter/agent/sender.py +68 -0
  7. trainmeter/agent/taps.py +144 -0
  8. trainmeter/agent/wrap.py +23 -0
  9. trainmeter/catalog.py +100 -0
  10. trainmeter/cli.py +382 -0
  11. trainmeter/commands.py +99 -0
  12. trainmeter/config.py +65 -0
  13. trainmeter/doctor.py +118 -0
  14. trainmeter/emit.py +64 -0
  15. trainmeter/engine.py +621 -0
  16. trainmeter/export/__init__.py +0 -0
  17. trainmeter/export/files.py +24 -0
  18. trainmeter/export/wandb.py +87 -0
  19. trainmeter/facts.py +50 -0
  20. trainmeter/flops.py +52 -0
  21. trainmeter/metrics.py +68 -0
  22. trainmeter/passport.py +327 -0
  23. trainmeter/peaks.py +92 -0
  24. trainmeter/records.py +97 -0
  25. trainmeter/replay.py +110 -0
  26. trainmeter/report.py +47 -0
  27. trainmeter/sources/__init__.py +1 -0
  28. trainmeter/sources/gpu.py +458 -0
  29. trainmeter/sources/host.py +135 -0
  30. trainmeter/supervisor/__init__.py +1 -0
  31. trainmeter/supervisor/ingest.py +86 -0
  32. trainmeter/supervisor/launcher.py +90 -0
  33. trainmeter/supervisor/live.py +133 -0
  34. trainmeter/supervisor/store.py +118 -0
  35. trainmeter/timeline.py +102 -0
  36. trainmeter/viewer.py +273 -0
  37. trainmeter/web/__init__.py +0 -0
  38. trainmeter/web/server.py +192 -0
  39. trainmeter/web/static/app.js +571 -0
  40. trainmeter/web/static/index.html +43 -0
  41. trainmeter/web/static/style.css +157 -0
  42. trainmeter-0.0.2.dist-info/METADATA +109 -0
  43. trainmeter-0.0.2.dist-info/RECORD +46 -0
  44. trainmeter-0.0.2.dist-info/WHEEL +4 -0
  45. trainmeter-0.0.2.dist-info/entry_points.txt +3 -0
  46. trainmeter-0.0.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,24 @@
1
+ """The passport as files next to the run log. Derived from the log, never the log itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..passport import to_markdown
11
+
12
+
13
+ def write_passport(directory: Path, data: dict[str, Any]) -> list[Path]:
14
+ """Write `passport.json` and `passport.md` atomically. Returns the paths written."""
15
+ written = []
16
+ for name, text in (
17
+ ("passport.json", json.dumps(data, indent=2, allow_nan=False) + "\n"),
18
+ ("passport.md", to_markdown(data)),
19
+ ):
20
+ tmp = directory / f".{name}.tmp"
21
+ tmp.write_text(text, encoding="utf-8")
22
+ os.replace(tmp, directory / name)
23
+ written.append(directory / name)
24
+ return written
@@ -0,0 +1,87 @@
1
+ """Export a finished run to Weights & Biases. Explicit, and only when the owner runs it.
2
+
3
+ `tm export wandb [RUN] --project P` replays the run log and creates one W&B run: the series the
4
+ loop reported under their own keys, the derived metrics (tokens/s, MFU, OFU, the utilization
5
+ ladder, per-GPU counters) under `trainmeter/`, the passport as the run config and summary, and the
6
+ passport files. Nothing leaves the node unless this command is run, and no price is sent.
7
+
8
+ Verified against wandb 0.30.0 in offline mode and against a fake module in tests. Not run against
9
+ the hosted service.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ from types import ModuleType
16
+ from typing import Any
17
+
18
+ from .. import catalog
19
+ from ..replay import Replayed
20
+
21
+ DERIVED = [
22
+ m[0] for m in catalog.METRICS if m[0] not in ("train_loss", "val_loss", "lr", "grad_norm")
23
+ ]
24
+
25
+
26
+ def _flat(prefix: str, obj: Any, out: dict[str, Any]) -> None:
27
+ if isinstance(obj, dict):
28
+ for k, v in obj.items():
29
+ _flat(f"{prefix}.{k}" if prefix else str(k), v, out)
30
+ elif isinstance(obj, (int, float, str, bool)) or obj is None:
31
+ out[prefix] = obj
32
+
33
+
34
+ def rows(replayed: Replayed) -> dict[int, dict[str, float]]:
35
+ """Step -> {key: value}. Reported series keep their raw keys, derived ones are prefixed."""
36
+ tl = replayed.timeline
37
+ names = [n for n in tl.names() if not n.startswith("gpu.")]
38
+ reported = [n for n in names if n not in DERIVED]
39
+ per_gpu = [n for n in tl.names() if n.startswith("gpu.")]
40
+ table: dict[int, dict[str, float]] = {}
41
+ for group, prefix in ((reported, ""), (DERIVED, "trainmeter/"), (per_gpu, "trainmeter/")):
42
+ data = tl.query(group, "step")
43
+ for name, points in data.items():
44
+ for step, value in points:
45
+ table.setdefault(int(step), {})[f"{prefix}{name}"] = value
46
+ return table
47
+
48
+
49
+ def export(
50
+ replayed: Replayed,
51
+ project: str,
52
+ entity: str | None = None,
53
+ name: str | None = None,
54
+ wandb: ModuleType | None = None,
55
+ ) -> str:
56
+ wandb = wandb or importlib.import_module("wandb")
57
+ passport = replayed.passport()
58
+ config: dict[str, Any] = {}
59
+ _flat(
60
+ "",
61
+ {k: passport[k] for k in ("model", "topology", "versions", "hardware") if k in passport},
62
+ config,
63
+ )
64
+ run = wandb.init(
65
+ project=project,
66
+ entity=entity,
67
+ name=name or replayed.path.name,
68
+ config=config,
69
+ job_type="trainmeter-import",
70
+ tags=["trainmeter"],
71
+ )
72
+ for step, row in sorted(rows(replayed).items()):
73
+ run.log(row, step=step)
74
+ summary: dict[str, Any] = {}
75
+ _flat(
76
+ "",
77
+ {k: passport[k] for k in ("training", "efficiency", "flops", "results") if k in passport},
78
+ summary,
79
+ )
80
+ run.summary.update({k: v for k, v in summary.items() if v is not None})
81
+ for fname in ("passport.json", "passport.md"):
82
+ path = replayed.path / fname
83
+ if path.is_file():
84
+ run.save(str(path), base_path=str(replayed.path), policy="now")
85
+ url = getattr(run, "url", None)
86
+ run.finish()
87
+ return url or f"offline run for {replayed.path.name}"
trainmeter/facts.py ADDED
@@ -0,0 +1,50 @@
1
+ """Facts with provenance, and the rule that decides between two values for the same key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ # Highest precedence first. `measured` values have their own keys and never compete, so it sits
9
+ # with `detected`.
10
+ PRECEDENCE = ("declared", "emitted", "tapped", "detected", "measured", "assumed")
11
+
12
+
13
+ def rank(source: str) -> int:
14
+ return PRECEDENCE.index(source) if source in PRECEDENCE else len(PRECEDENCE)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Fact:
19
+ key: str
20
+ value: Any
21
+ source: str
22
+ note: str | None = None
23
+
24
+
25
+ class FactStore:
26
+ """Keeps, per key, the value from the highest-precedence source; the latest wins on a tie."""
27
+
28
+ def __init__(self) -> None:
29
+ self._facts: dict[str, Fact] = {}
30
+ self.conflicts: list[tuple[Fact, Fact]] = [] # (kept, ignored) with different values
31
+
32
+ def offer(self, key: str, value: Any, source: str, note: str | None = None) -> None:
33
+ new = Fact(key, value, source, note)
34
+ old = self._facts.get(key)
35
+ if old is None or rank(source) <= rank(old.source):
36
+ if old is not None and old.value != value and rank(source) < rank(old.source):
37
+ self.conflicts.append((new, old))
38
+ self._facts[key] = new
39
+ elif old.value != value:
40
+ self.conflicts.append((old, new))
41
+
42
+ def get(self, key: str) -> Fact | None:
43
+ return self._facts.get(key)
44
+
45
+ def value(self, key: str, default: Any = None) -> Any:
46
+ fact = self._facts.get(key)
47
+ return default if fact is None else fact.value
48
+
49
+ def items(self) -> dict[str, Fact]:
50
+ return dict(self._facts)
trainmeter/flops.py ADDED
@@ -0,0 +1,52 @@
1
+ """FLOPs per token for a dense decoder-only transformer, under named conventions.
2
+
3
+ Frameworks disagree on what to count, and the disagreement is large: on one
4
+ 1.04B model the four conventions below span 5.83 to 6.69 GFLOP per token, about
5
+ 15%. A number without its convention is not comparable, so every function here
6
+ takes the convention explicitly and there is no silent default in reports.
7
+
8
+ Symbols: N total parameters, E embedding-table parameters (token and learned
9
+ position tables, not an untied output head), L layers, d model width, T
10
+ sequence length. Training counts forward plus backward, hence the factor 6.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Literal
17
+
18
+ Convention = Literal["titan", "megatron", "hf", "all_params"]
19
+
20
+ CONVENTIONS: dict[str, str] = {
21
+ "titan": "6(N-E) + 12*L*d*T (TorchTitan, nanochat; full attention square)",
22
+ "megatron": "6(N-E) + 6*L*d*T (Megatron-LM; causal mask halves attention)",
23
+ "hf": "6(N-E) (HF Trainer total_flos; no attention term)",
24
+ "all_params": "6N + 12*L*d*T (nanoGPT-style; embeddings counted as matmuls)",
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ModelShape:
30
+ n_params: int
31
+ n_embedding_params: int
32
+ n_layers: int
33
+ d_model: int
34
+ seq_len: int
35
+
36
+
37
+ def flops_per_token(shape: ModelShape, convention: Convention) -> float:
38
+ dense = 6 * (shape.n_params - shape.n_embedding_params)
39
+ attn_full = 12 * shape.n_layers * shape.d_model * shape.seq_len
40
+ if convention == "titan":
41
+ return dense + attn_full
42
+ if convention == "megatron":
43
+ return dense + attn_full / 2
44
+ if convention == "hf":
45
+ return dense
46
+ if convention == "all_params":
47
+ return 6 * shape.n_params + attn_full
48
+ raise ValueError(f"unknown convention {convention!r}; expected one of {list(CONVENTIONS)}")
49
+
50
+
51
+ def all_conventions(shape: ModelShape) -> dict[str, float]:
52
+ return {c: flops_per_token(shape, c) for c in CONVENTIONS} # type: ignore[arg-type]
trainmeter/metrics.py ADDED
@@ -0,0 +1,68 @@
1
+ """Pure metric formulas. No I/O, no GPU: everything here is unit-testable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+
8
+ def mfu(flops_per_token: float, tokens_per_s: float, n_gpus: int, peak_flops: float) -> float:
9
+ """Model FLOPs utilization as a fraction of the dense peak of all GPUs."""
10
+ return flops_per_token * tokens_per_s / (n_gpus * peak_flops)
11
+
12
+
13
+ def ofu(tensor_active: float, sm_clock_mhz: float, max_sm_clock_mhz: float) -> float:
14
+ """Counter-based MFU proxy: tensor pipe activity scaled by the clock ratio.
15
+
16
+ tensor_active is a fraction in [0, 1]. See arXiv 2605.20799. It counts busy
17
+ tensor-pipe cycles, so it includes recompute and padding that MFU excludes.
18
+ """
19
+ return tensor_active * sm_clock_mhz / max_sm_clock_mhz
20
+
21
+
22
+ def step_intensity(mfu_value: float, dram_active: float, ridge: float) -> float:
23
+ """Aggregate useful FLOPs per byte of HBM traffic over a sampling window.
24
+
25
+ achieved FLOP/s = mfu * peak_flops; achieved B/s = dram_active * peak_bw,
26
+ so the ratio is ridge * mfu / dram_active. It is an average over many
27
+ kernels, not a roofline verdict for any one of them.
28
+ """
29
+ if dram_active <= 0:
30
+ return math.inf
31
+ return ridge * mfu_value / dram_active
32
+
33
+
34
+ def eta_seconds(remaining_tokens: float, tokens_per_s: float) -> float:
35
+ if tokens_per_s <= 0:
36
+ return math.inf
37
+ return max(remaining_tokens, 0.0) / tokens_per_s
38
+
39
+
40
+ def cost_usd(price_per_hour: float, billed_seconds: float) -> float:
41
+ return price_per_hour * billed_seconds / 3600.0
42
+
43
+
44
+ def cost_per_million_tokens(price_per_hour: float, billed_seconds: float, tokens: float) -> float:
45
+ if tokens <= 0:
46
+ return math.inf
47
+ return cost_usd(price_per_hour, billed_seconds) / tokens * 1e6
48
+
49
+
50
+ class Ema:
51
+ """Exponential moving average that ignores its first `warmup` samples.
52
+
53
+ The first steps include torch.compile and allocator warmup; averaging them
54
+ in makes the ETA wrong for a long time.
55
+ """
56
+
57
+ def __init__(self, alpha: float = 0.1, warmup: int = 3) -> None:
58
+ self.alpha = alpha
59
+ self.warmup = warmup
60
+ self.seen = 0
61
+ self.value: float | None = None
62
+
63
+ def update(self, x: float) -> float | None:
64
+ self.seen += 1
65
+ if self.seen <= self.warmup:
66
+ return self.value
67
+ self.value = x if self.value is None else self.alpha * x + (1 - self.alpha) * self.value
68
+ return self.value
trainmeter/passport.py ADDED
@@ -0,0 +1,327 @@
1
+ """The run passport: what goes into a model card, from a snapshot. Pure.
2
+
3
+ Physics only: no price, no hostname, no working directory, no environment. The command line is
4
+ the one recorded in the run header, where the values of secret-looking flags are already replaced.
5
+ Every number keeps its convention and provenance, and what could not be computed is listed under
6
+ `not_available` with the reason, never as a zero.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ SCHEMA = 1
14
+
15
+
16
+ def _fact(facts: dict[str, Any], key: str) -> dict[str, Any] | None:
17
+ fact = facts.get(key)
18
+ if fact is None:
19
+ return None
20
+ out = {"value": fact["value"], "source": fact["source"]}
21
+ if fact.get("note"):
22
+ out["note"] = fact["note"]
23
+ return out
24
+
25
+
26
+ def build(
27
+ snap: dict[str, Any],
28
+ header: dict[str, Any],
29
+ facts: dict[str, Any],
30
+ hello: dict[str, Any],
31
+ exit_info: dict[str, Any] | None = None,
32
+ ) -> dict[str, Any]:
33
+ """`facts` is `{key: {value, source, note}}` as served by `/api/run`."""
34
+ passport: dict[str, Any] = {
35
+ "schema": SCHEMA,
36
+ "generated_by": f"trainmeter {header.get('tm_version')}",
37
+ }
38
+ passport["run"] = {
39
+ "command": header.get("argv"),
40
+ "elapsed_s": (snap.get("time") or {}).get("elapsed_s"),
41
+ "finished": (snap.get("time") or {}).get("finished", False),
42
+ **({"exit_status": exit_info["exit_status"]} if exit_info else {}),
43
+ }
44
+ model = {
45
+ key: _fact(facts, key)
46
+ for key in ("n_params", "n_embedding_params", "n_layers", "d_model", "seq_len")
47
+ }
48
+ model["moe_experts"] = _fact(facts, "moe_experts")
49
+ passport["model"] = {k: v for k, v in model.items() if v is not None}
50
+ fpt = snap.get("flops_per_token")
51
+ flops: dict[str, Any] = {}
52
+ if fpt is not None:
53
+ flops["per_token"] = fpt
54
+ if "conventions" in snap:
55
+ flops["conventions"] = snap["conventions"]
56
+ if "counted" in snap:
57
+ flops["counted"] = snap["counted"]
58
+ for key in ("flops_spent", "flops_required", "flops_observed"):
59
+ if key in snap:
60
+ flops[key] = snap[key]
61
+ passport["flops"] = flops
62
+ passport["training"] = {
63
+ k: snap[k]
64
+ for k in ("steps", "tokens_seen", "tokens_per_s", "budget_tokens", "progress")
65
+ if k in snap
66
+ }
67
+ passport["efficiency"] = {
68
+ k: snap[k] for k in ("mfu", "ofu", "mfu_ofu_gap", "arithmetic_intensity") if k in snap
69
+ }
70
+ if "goodput" in (snap.get("time") or {}):
71
+ passport["efficiency"]["goodput"] = snap["time"]["goodput"]
72
+ passport["hardware"] = {
73
+ k: snap[k] for k in ("n_gpus", "devices", "peak", "gpu_versions") if k in snap
74
+ }
75
+ passport["topology"] = {
76
+ "world_size": hello.get("world_size"),
77
+ **({"dp": _fact(facts, "dp")} if _fact(facts, "dp") else {}),
78
+ }
79
+ passport["versions"] = {
80
+ k: v
81
+ for k, v in {
82
+ "trainmeter": header.get("tm_version"),
83
+ "python": hello.get("python"),
84
+ "torch": hello.get("torch"),
85
+ "cuda": hello.get("cuda"),
86
+ }.items()
87
+ if v
88
+ }
89
+ passport["results"] = {
90
+ name: entry
91
+ for name, entry in (snap.get("series") or {}).items()
92
+ if name in ("train_loss", "val_loss")
93
+ }
94
+ if "parts" in (snap.get("time") or {}):
95
+ passport["time"] = {"parts": snap["time"]["parts"], "goodput": snap["time"].get("goodput")}
96
+ if "plan" in snap:
97
+ passport["plan"] = snap["plan"]
98
+ passport["warnings"] = snap.get("warnings", [])
99
+ passport["not_available"] = snap.get("absent", {})
100
+ return passport
101
+
102
+
103
+ # ---- Markdown -----------------------------------------------------------------------------
104
+
105
+
106
+ def _si(v: float) -> str:
107
+ for limit, suffix in ((1e15, "P"), (1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k")):
108
+ if abs(v) >= limit:
109
+ return f"{v / limit:.3g} {suffix}"
110
+ return f"{v:.4g}"
111
+
112
+
113
+ def _hours(s: float) -> str:
114
+ """Seconds as the largest sensible unit: 12.3 s, 4.5 min, 1.25 h."""
115
+ if s < 60:
116
+ return f"{s:.1f} s"
117
+ if s < 3600:
118
+ return f"{s / 60:.1f} min"
119
+ return f"{s / 3600:.2f} h"
120
+
121
+
122
+ def _val(entry: dict[str, Any] | None) -> str:
123
+ if not entry:
124
+ return "not available"
125
+ tag = f" ({entry['source']})" if entry.get("source") else ""
126
+ value = entry["value"]
127
+ return f"{_si(value) if isinstance(value, (int, float)) else value}{tag}"
128
+
129
+
130
+ def to_markdown(p: dict[str, Any]) -> str:
131
+ """A model-card section. One sentence per line, tables for numbers."""
132
+ lines = ["# Run passport", ""]
133
+ run = p.get("run", {})
134
+ if run.get("command"):
135
+ lines += ["Command:", "", "```", " ".join(run["command"]), "```", ""]
136
+ if run.get("elapsed_s") is not None:
137
+ state = "finished" if run.get("finished") else "still running"
138
+ exit_text = f", exit status {run['exit_status']}" if "exit_status" in run else ""
139
+ lines += [f"Wall time {_hours(run['elapsed_s'])} ({state}{exit_text}).", ""]
140
+
141
+ model = p.get("model", {})
142
+ lines += ["## Model", ""]
143
+ if model:
144
+ lines += ["| Item | Value |", "| --- | --- |"]
145
+ labels = {
146
+ "n_params": "Parameters",
147
+ "n_embedding_params": "Embedding parameters",
148
+ "n_layers": "Layers",
149
+ "d_model": "Width (d_model)",
150
+ "seq_len": "Sequence length",
151
+ "moe_experts": "Experts (mixture of experts)",
152
+ }
153
+ lines += [f"| {labels[k]} | {_val(v)} |" for k, v in model.items() if k in labels]
154
+ else:
155
+ lines.append("Not available: the agent saw no model, and none was declared.")
156
+ lines.append("")
157
+
158
+ fl = p.get("flops", {})
159
+ lines += ["## FLOPs", ""]
160
+ fpt = fl.get("per_token")
161
+ if fpt:
162
+ if fpt.get("value") is not None:
163
+ lines.append(
164
+ f"FLOPs per token: {_si(fpt['value'])}, convention `{fpt['convention']}` "
165
+ f"({fpt['source']})."
166
+ )
167
+ else:
168
+ lo, hi = fpt["range"]
169
+ lines.append(
170
+ f"FLOPs per token: {_si(lo)} to {_si(hi)} across the four conventions, "
171
+ "convention not chosen."
172
+ )
173
+ else:
174
+ lines.append("FLOPs per token: not available.")
175
+ lines.append("")
176
+ if fl.get("counted"):
177
+ c = fl["counted"]
178
+ lines += [
179
+ f"Counted (`FlopCounterMode`) on one real step: {_si(c['per_token'])} FLOPs per token.",
180
+ "It counts executed operators, so it includes recompute and is not model FLOPs.",
181
+ "",
182
+ ]
183
+ rows = [
184
+ (name, label)
185
+ for name, label in (
186
+ ("flops_spent", "FLOPs spent (modeled)"),
187
+ ("flops_required", "FLOPs required (budget)"),
188
+ )
189
+ if name in fl
190
+ ]
191
+ if rows or "flops_observed" in fl:
192
+ lines += ["| Quantity | Value |", "| --- | --- |"]
193
+ lines += [f"| {label} | {_si(fl[name])} |" for name, label in rows]
194
+ if "flops_observed" in fl:
195
+ lines.append(
196
+ f"| FLOPs observed (counters, approximate) | {_si(fl['flops_observed']['value'])} |"
197
+ )
198
+ lines.append("")
199
+
200
+ tr = p.get("training", {})
201
+ lines += ["## Training", ""]
202
+ if tr:
203
+ lines += ["| Item | Value |", "| --- | --- |"]
204
+ for key, label in (("steps", "Optimizer steps"), ("tokens_seen", "Tokens seen")):
205
+ if key in tr:
206
+ lines.append(f"| {label} | {_si(tr[key])} |")
207
+ if "tokens_per_s" in tr:
208
+ lines.append(f"| Tokens per second (moving average) | {_si(tr['tokens_per_s'])} |")
209
+ if "budget_tokens" in tr:
210
+ lines.append(f"| Token budget | {_si(tr['budget_tokens'])} |")
211
+ if "progress" in tr:
212
+ lines.append(f"| Progress | {tr['progress'] * 100:.1f}% |")
213
+ else:
214
+ lines.append("Not available: no steps were seen.")
215
+ lines.append("")
216
+
217
+ res = p.get("results", {})
218
+ if res:
219
+ lines += ["## Results", "", "| Series | Last value | Source |", "| --- | --- | --- |"]
220
+ for name, e in res.items():
221
+ lines.append(f"| {name} | {e['value']:.4g} | {e.get('source', '')} |")
222
+ lines.append("")
223
+
224
+ eff = p.get("efficiency", {})
225
+ lines += ["## Efficiency", ""]
226
+ if eff:
227
+ lines += ["| Metric | Value | Label |", "| --- | --- | --- |"]
228
+ if "mfu" in eff:
229
+ m = eff["mfu"]
230
+ shown = (
231
+ f"{m['value'] * 100:.1f}%"
232
+ if "value" in m
233
+ else (f"{m['range'][0] * 100:.1f}-{m['range'][1] * 100:.1f}%")
234
+ )
235
+ lines.append(
236
+ f"| MFU | {shown} | convention `{m['convention']}`, peak {m['peak']} dense BF16 |"
237
+ )
238
+ if "ofu" in eff:
239
+ lines.append(f"| OFU | {eff['ofu']['value'] * 100:.1f}% | counters |")
240
+ if "arithmetic_intensity" in eff:
241
+ a = eff["arithmetic_intensity"]
242
+ lines.append(f"| Arithmetic intensity | {_si(a['value'])} FLOP/byte | {a['note']} |")
243
+ if "goodput" in eff:
244
+ lines.append(
245
+ f"| Goodput | {eff['goodput'] * 100:.1f}% | time in counted steps / wall time |"
246
+ )
247
+ else:
248
+ lines.append("Not available.")
249
+ lines.append("")
250
+
251
+ hw = p.get("hardware", {})
252
+ lines += ["## Hardware", ""]
253
+ if hw.get("n_gpus"):
254
+ n = hw["n_gpus"]
255
+ lines.append(f"{n['value']} GPU(s) ({n['source']}).")
256
+ names = sorted({d.get("name") for d in hw.get("devices", []) if d.get("name")})
257
+ if names:
258
+ lines.append(f"Model: {', '.join(names)}.")
259
+ if hw.get("peak"):
260
+ pk = hw["peak"]
261
+ lines.append(
262
+ f"Dense BF16 peak {_si(pk['bf16_dense_flops'])}FLOP/s and HBM "
263
+ f"{_si(pk['hbm_bytes_per_s'])}B/s per GPU, from {pk['source']}."
264
+ )
265
+ if hw.get("gpu_versions"):
266
+ lines.append(
267
+ "Driver: " + ", ".join(f"{k} {v}" for k, v in hw["gpu_versions"].items()) + "."
268
+ )
269
+ else:
270
+ lines.append("Not available: no GPU was seen.")
271
+ lines.append("")
272
+
273
+ topo, ver = p.get("topology", {}), p.get("versions", {})
274
+ lines += ["## Software and topology", ""]
275
+ if topo.get("world_size"):
276
+ lines.append(f"World size {topo['world_size']}.")
277
+ lines += [f"- {k}: {v}" for k, v in ver.items()]
278
+ lines.append("")
279
+
280
+ if p.get("time", {}).get("parts"):
281
+ lines += ["## Where the time went", "", "| Part | Time | Basis |", "| --- | --- | --- |"]
282
+ for part in p["time"]["parts"]:
283
+ basis = (
284
+ "measured"
285
+ if part["measured"]
286
+ else ("not measured" if part["s"] is None else "remainder")
287
+ )
288
+ secs = "-" if part["s"] is None else _hours(part["s"])
289
+ lines.append(f"| {part['label']} | {secs} | {basis} |")
290
+ lines.append("")
291
+
292
+ plan = p.get("plan")
293
+ if plan:
294
+ lines += [
295
+ "## Plan against actual",
296
+ "",
297
+ "| Item | Planned | Measured |",
298
+ "| --- | --- | --- |",
299
+ ]
300
+ if "mfu" in plan:
301
+ m = plan["mfu"]["measured"]
302
+ measured = (
303
+ "not available" if m is None else f"{m * 100:.1f}% ({plan['mfu']['convention']})"
304
+ )
305
+ lines.append(f"| MFU | {plan['mfu']['planned'] * 100:.1f}% | {measured} |")
306
+ if "tokens_per_s" in plan:
307
+ t = plan["tokens_per_s"]
308
+ lines.append(
309
+ f"| Tokens per second | {_si(t['planned'])} | "
310
+ f"{'not available' if t['measured'] is None else _si(t['measured'])} |"
311
+ )
312
+ if "hours" in plan:
313
+ h = plan["hours"]
314
+ proj = (
315
+ "not available" if h["projected"] is None else f"{h['projected']:.2f} h (projected)"
316
+ )
317
+ lines.append(f"| Wall time | {h['planned']:.2f} h | {proj} |")
318
+ lines.append("")
319
+
320
+ if p.get("warnings"):
321
+ lines += ["## Warnings", ""] + [f"- {w}" for w in p["warnings"]] + [""]
322
+ if p.get("not_available"):
323
+ lines += ["## Not available", ""]
324
+ lines += [f"- {k}: {v}" for k, v in p["not_available"].items()]
325
+ lines.append("")
326
+ lines.append(f"Generated by {p.get('generated_by')}.")
327
+ return "\n".join(lines) + "\n"