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,90 @@
1
+ """Run a command as a child process and stay out of its way.
2
+
3
+ Contract: the child inherits stdio and is not touched; its exit status is returned as a shell
4
+ would report it (128+N when killed by signal N); SIGTERM, SIGHUP and SIGQUIT sent to `tm` alone
5
+ are forwarded to the child; Ctrl-C reaches both processes through the terminal, so SIGINT is not
6
+ forwarded and `tm` never exits before the child.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import signal
12
+ import subprocess
13
+ import time
14
+ import warnings
15
+ from collections.abc import Callable, Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ FORWARDED = ("SIGTERM", "SIGHUP", "SIGQUIT")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Outcome:
24
+ status: int
25
+ signal: int | None
26
+ elapsed_s: float
27
+ pid: int | None = None
28
+ error: str | None = None
29
+
30
+
31
+ def exit_status(returncode: int) -> tuple[int, int | None]:
32
+ """(shell-style status, killing signal or None) for a `Popen.returncode`."""
33
+ if returncode < 0:
34
+ return 128 - returncode, -returncode
35
+ return returncode, None
36
+
37
+
38
+ def launch(
39
+ argv: list[str],
40
+ *,
41
+ env: Mapping[str, str] | None = None,
42
+ on_spawn: Callable[[int], None] | None = None,
43
+ clock: Callable[[], float] = time.monotonic,
44
+ ) -> Outcome:
45
+ state: dict[str, Any] = {"proc": None, "pending": []}
46
+
47
+ def forward(signum: int, _frame: Any) -> None:
48
+ proc = state["proc"]
49
+ if proc is None:
50
+ state["pending"].append(signum)
51
+ return
52
+ try:
53
+ proc.send_signal(signum)
54
+ except OSError:
55
+ pass
56
+
57
+ def ignore(_signum: int, _frame: Any) -> None:
58
+ # A Python handler, not SIG_IGN: an ignored signal would be inherited by the child.
59
+ return None
60
+
61
+ previous: dict[int, Any] = {}
62
+ handlers = {getattr(signal, n): forward for n in FORWARDED if hasattr(signal, n)}
63
+ handlers[signal.SIGINT] = ignore
64
+ try:
65
+ for signum, handler in handlers.items():
66
+ previous[signum] = signal.signal(signum, handler)
67
+ except ValueError: # not the main thread: no handlers, plain child
68
+ previous.clear()
69
+
70
+ start = clock()
71
+ try:
72
+ try:
73
+ proc = subprocess.Popen(argv, env=None if env is None else dict(env))
74
+ except FileNotFoundError as exc:
75
+ return Outcome(127, None, clock() - start, error=str(exc))
76
+ except OSError as exc:
77
+ return Outcome(126, None, clock() - start, error=str(exc))
78
+ state["proc"] = proc
79
+ for signum in state["pending"]:
80
+ forward(signum, None)
81
+ if on_spawn is not None:
82
+ try:
83
+ on_spawn(proc.pid)
84
+ except Exception as exc: # noqa: BLE001 - monitoring must not touch the child
85
+ warnings.warn(f"trainmeter monitoring failed to start: {exc!r}", stacklevel=2)
86
+ status, killed_by = exit_status(proc.wait())
87
+ return Outcome(status, killed_by, clock() - start, pid=proc.pid)
88
+ finally:
89
+ for signum, handler in previous.items():
90
+ signal.signal(signum, handler)
@@ -0,0 +1,133 @@
1
+ """Live state behind the dashboard: the engine and the timeline, fed under one lock.
2
+
3
+ Records come from the ingest thread and the samplers; the web threads only read. A ticker takes
4
+ one snapshot per second, samples the derived metrics into the timeline and keeps the snapshot
5
+ for readers, so a request never waits on the engine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ import time
12
+ import warnings
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from ..engine import Engine
17
+ from ..timeline import Timeline
18
+
19
+
20
+ class LiveState:
21
+ def __init__(
22
+ self,
23
+ engine: Engine,
24
+ header: dict[str, Any] | None = None,
25
+ mono: Callable[[], float] = time.monotonic,
26
+ ) -> None:
27
+ self.engine = engine
28
+ self.timeline = Timeline()
29
+ self.header = header or {}
30
+ self._mono = mono
31
+ self.t0 = mono()
32
+ self.lock = threading.Lock()
33
+ self.running = True
34
+ self.exit: dict[str, Any] | None = None
35
+ self.replay: str | None = None # the run directory name when this is a replay
36
+ self._latest: dict[str, Any] = self._envelope({})
37
+ self._stop = threading.Event()
38
+ self._thread = threading.Thread(target=self._run, name="trainmeter-live", daemon=True)
39
+
40
+ @classmethod
41
+ def from_replay(cls, replayed: Any) -> LiveState:
42
+ """A finished run, served as it ended: no ticker, no clock, the log's own numbers."""
43
+ live = cls(replayed.engine, replayed.header, mono=lambda: replayed.t_end)
44
+ live.t0 = replayed.t_start
45
+ live.timeline = replayed.timeline
46
+ live.replay = replayed.path.name
47
+ live.running = False
48
+ live.exit = replayed.exit
49
+ live._latest = live._envelope(replayed.snapshot)
50
+ return live
51
+
52
+ def feed(self, record: dict[str, Any]) -> None:
53
+ with self.lock:
54
+ self.engine.feed(record)
55
+ self.timeline.feed(record, record["t"] - self.t0, self.engine.position())
56
+
57
+ def _envelope(self, snap: dict[str, Any]) -> dict[str, Any]:
58
+ elapsed = self._mono() - self.t0
59
+ out: dict[str, Any] = {
60
+ "snapshot": snap,
61
+ "elapsed_s": elapsed,
62
+ "running": self.running,
63
+ "exit": self.exit,
64
+ "replay": self.replay,
65
+ }
66
+ n_gpus = (snap.get("n_gpus") or {}).get("value")
67
+ if n_gpus:
68
+ out["gpu_hours"] = elapsed * n_gpus / 3600 # view-time; the price stays in the browser
69
+ return out
70
+
71
+ def tick(self) -> None:
72
+ """One snapshot: kept for readers and sampled into the timeline. Never raises."""
73
+ try:
74
+ with self.lock:
75
+ snap = self.engine.snapshot(self._mono())
76
+ t = self._mono() - self.t0
77
+ self.timeline.add_snapshot(snap, t, self.engine.position())
78
+ self._latest = self._envelope(snap)
79
+ except Exception as exc: # noqa: BLE001 - the dashboard must not cost a run
80
+ warnings.warn(f"trainmeter: snapshot failed: {exc!r}", stacklevel=2)
81
+
82
+ def latest(self) -> dict[str, Any]:
83
+ with self.lock:
84
+ return self._latest
85
+
86
+ def facts(self) -> dict[str, Any]:
87
+ with self.lock:
88
+ return {
89
+ k: {"value": f.value, "source": f.source, "note": f.note}
90
+ for k, f in self.engine.facts.items().items()
91
+ }
92
+
93
+ def passport(self) -> dict[str, Any]:
94
+ """The run passport of the latest snapshot, plus its Markdown."""
95
+ from .. import passport
96
+
97
+ with self.lock:
98
+ snap = self._latest["snapshot"]
99
+ data = passport.build(
100
+ snap, self.header, self.facts_unlocked(), self.engine.hello, self.exit
101
+ )
102
+ return {"passport": data, "markdown": passport.to_markdown(data)}
103
+
104
+ def facts_unlocked(self) -> dict[str, Any]:
105
+ return {
106
+ k: {"value": f.value, "source": f.source, "note": f.note}
107
+ for k, f in self.engine.facts.items().items()
108
+ }
109
+
110
+ def series(self, names: list[str], x: str, since: float | None) -> dict[str, Any]:
111
+ with self.lock:
112
+ return self.timeline.query(names, x, since)
113
+
114
+ def series_names(self) -> list[str]:
115
+ with self.lock:
116
+ return self.timeline.names()
117
+
118
+ def finish(self, exit_info: dict[str, Any]) -> None:
119
+ self.running = False
120
+ self.exit = exit_info
121
+ self.tick()
122
+
123
+ def start(self) -> None:
124
+ self._thread.start()
125
+
126
+ def stop(self) -> None:
127
+ self._stop.set()
128
+ if self._thread.is_alive():
129
+ self._thread.join(timeout=2.0)
130
+
131
+ def _run(self) -> None:
132
+ while not self._stop.wait(1.0):
133
+ self.tick()
@@ -0,0 +1,118 @@
1
+ """The run directory and its single writer.
2
+
3
+ `events.jsonl` is appended by the supervisor only. Every failure here is reported once and
4
+ then swallowed: a full disk must never cost a training run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import threading
13
+ import time
14
+ import warnings
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any, TextIO
18
+
19
+ from ..records import encode, make_record
20
+
21
+ RUNS_DIR = Path(".trainmeter") / "runs"
22
+
23
+
24
+ def _safe_name(name: str) -> str:
25
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.") or "run"
26
+
27
+
28
+ class RunStore:
29
+ def __init__(
30
+ self,
31
+ path: Path,
32
+ mono: Callable[[], float] = time.monotonic,
33
+ wall: Callable[[], float] = time.time,
34
+ ) -> None:
35
+ self.path = path
36
+ self.run_id = path.name
37
+ self._mono = mono
38
+ self.mono0 = mono()
39
+ self.wall0 = wall()
40
+ self._lock = threading.Lock()
41
+ self._warned = False
42
+ self._file: TextIO | None = open(path / "events.jsonl", "a", encoding="utf-8") # noqa: SIM115
43
+
44
+ @classmethod
45
+ def create(
46
+ cls,
47
+ base: Path,
48
+ name: str,
49
+ mono: Callable[[], float] = time.monotonic,
50
+ wall: Callable[[], float] = time.time,
51
+ ) -> RunStore:
52
+ """Create `<base>/.trainmeter/runs/<UTC timestamp>-<name>/`, adding a suffix on a clash."""
53
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(wall()))
54
+ runs = Path(base) / RUNS_DIR
55
+ runs.mkdir(parents=True, exist_ok=True)
56
+ stem = f"{stamp}-{_safe_name(name)}"
57
+ for n in range(1, 1000):
58
+ path = runs / (stem if n == 1 else f"{stem}-{n}")
59
+ try:
60
+ path.mkdir()
61
+ except FileExistsError:
62
+ continue
63
+ return cls(path, mono, wall)
64
+ raise FileExistsError(f"no free run directory for {stem}")
65
+
66
+ def _warn_once(self, message: str) -> None:
67
+ if not self._warned:
68
+ self._warned = True
69
+ warnings.warn(f"trainmeter run log: {message}", stacklevel=3)
70
+
71
+ def emit(
72
+ self, kind: str, src: str, d: dict[str, Any] | None = None, *, pid: int | None = None
73
+ ) -> None:
74
+ """Append one record. Never raises."""
75
+ try:
76
+ line = encode(make_record(kind, src, self._mono(), d, pid=pid))
77
+ except (TypeError, ValueError) as exc:
78
+ self._warn_once(f"dropped a record that is not valid JSON: {exc}")
79
+ return
80
+ self._write_line(line)
81
+
82
+ def _write_line(self, line: str) -> None:
83
+ with self._lock:
84
+ if self._file is None:
85
+ return
86
+ try:
87
+ self._file.write(line + "\n")
88
+ self._file.flush()
89
+ except OSError as exc:
90
+ self._warn_once(f"stopped writing: {exc}")
91
+ self._file = None
92
+
93
+ def append_record(self, record: dict[str, Any]) -> None:
94
+ """Append a record that already has its envelope, such as one received from an agent."""
95
+ try:
96
+ line = encode(record)
97
+ except (TypeError, ValueError) as exc:
98
+ self._warn_once(f"dropped a record that is not valid JSON: {exc}")
99
+ return
100
+ self._write_line(line)
101
+
102
+ def write_json(self, name: str, obj: dict[str, Any]) -> None:
103
+ """Write `name` atomically. Never raises."""
104
+ try:
105
+ tmp = self.path / f".{name}.tmp"
106
+ tmp.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
107
+ os.replace(tmp, self.path / name)
108
+ except (OSError, TypeError, ValueError) as exc:
109
+ self._warn_once(f"could not write {name}: {exc}")
110
+
111
+ def close(self) -> None:
112
+ with self._lock:
113
+ if self._file is not None:
114
+ try:
115
+ self._file.close()
116
+ except OSError:
117
+ pass
118
+ self._file = None
trainmeter/timeline.py ADDED
@@ -0,0 +1,102 @@
1
+ """Chart history in memory: named series of points that can be read against any x axis.
2
+
3
+ Pure. It is fed from the same records as the engine, so a replay of a run log rebuilds it.
4
+ The x axes are `step`, `tokens`, `flops` (modeled FLOPs spent) and `time` (seconds since the
5
+ run started). A point whose x is unknown, such as `flops` before a FLOPs per token exists, is
6
+ left out of that axis instead of being placed at zero.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from . import catalog
14
+
15
+ AXES = ("step", "tokens", "flops", "time")
16
+ _LADDER = ("gpu_util", "sm_active", "tensor_active")
17
+
18
+
19
+ class Timeline:
20
+ def __init__(self, max_points: int = 4000) -> None:
21
+ self._max = max_points
22
+ # name -> list of (time, step, tokens, flops, value)
23
+ self._series: dict[str, list[tuple[float, Any, Any, Any, float]]] = {}
24
+ self._explicit_train = False
25
+
26
+ def add(self, name: str, value: Any, t: float, pos: dict[str, Any]) -> None:
27
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
28
+ return
29
+ if value in (float("inf"), float("-inf")):
30
+ return
31
+ points = self._series.setdefault(name, [])
32
+ points.append((t, pos.get("step"), pos.get("tokens"), pos.get("flops"), float(value)))
33
+ if len(points) > self._max:
34
+ del points[1:-1:2] # halve the resolution, keep the first and the last point
35
+
36
+ # ---- feeding -----------------------------------------------------------------------
37
+
38
+ def feed(self, record: dict[str, Any], t: float, pos: dict[str, Any]) -> None:
39
+ """`t` is seconds since the run started, `pos` the engine's position after the record."""
40
+ d = record.get("d") or {}
41
+ kind = record.get("kind")
42
+ if kind == "metric" and isinstance(d.get("key"), str):
43
+ here = dict(pos)
44
+ if isinstance(d.get("step"), int):
45
+ here["step"] = d["step"]
46
+ key = d["key"]
47
+ if catalog.canonical(key) == "train_loss":
48
+ self._explicit_train = True
49
+ self.add(key, d.get("value"), t, here)
50
+ elif kind == "sample.gpu":
51
+ for row in d.get("gpus") or []:
52
+ for field, *_ in catalog.GPU_FIELDS:
53
+ if field in row:
54
+ self.add(f"gpu.{row.get('index')}.{field}", row[field], t, pos)
55
+
56
+ def add_snapshot(self, snap: dict[str, Any], t: float, pos: dict[str, Any]) -> None:
57
+ """The derived metrics, sampled once per tick."""
58
+ for name in ("tokens_per_s", "step_time_s", "progress", "flops_spent"):
59
+ self.add(name, snap.get(name), t, pos)
60
+ for name in ("mfu", "ofu", "mfu_ofu_gap", "arithmetic_intensity", "flops_observed"):
61
+ self.add(name, (snap.get(name) or {}).get("value"), t, pos)
62
+ self.add("goodput", (snap.get("time") or {}).get("goodput"), t, pos)
63
+ host = snap.get("host") or {}
64
+ self.add("host_cpu_cores", host.get("cpu_cores"), t, pos)
65
+ self.add("host_rss_b", host.get("rss_b"), t, pos)
66
+ mean = (snap.get("ladder") or {}).get("mean") or {}
67
+ for name in _LADDER:
68
+ self.add(name, mean.get(name), t, pos)
69
+
70
+ # ---- reading -----------------------------------------------------------------------
71
+
72
+ def _keys_for(self, name: str) -> str | None:
73
+ """The stored key behind a canonical name: the name itself, or the first alias seen."""
74
+ if name in self._series:
75
+ return name
76
+ for key in self._series:
77
+ if catalog.canonical(key) == name:
78
+ return key
79
+ if name == "train_loss" and not self._explicit_train and catalog.BARE_LOSS in self._series:
80
+ return catalog.BARE_LOSS
81
+ return None
82
+
83
+ def names(self) -> list[str]:
84
+ return sorted(self._series)
85
+
86
+ def query(
87
+ self, names: list[str], x: str = "step", since: float | None = None
88
+ ) -> dict[str, list[list[float]]]:
89
+ if x not in AXES:
90
+ raise ValueError(f"x must be one of {', '.join(AXES)}")
91
+ column = {"time": 0, "step": 1, "tokens": 2, "flops": 3}[x]
92
+ out: dict[str, list[list[float]]] = {}
93
+ for name in names:
94
+ key = self._keys_for(name)
95
+ points = self._series.get(key, []) if key else []
96
+ rows = [
97
+ [p[column], p[4]]
98
+ for p in points
99
+ if p[column] is not None and (since is None or p[column] > since)
100
+ ]
101
+ out[name] = rows
102
+ return out