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