llmperfscope 0.1.0__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.
llmperfscope/gpu.py ADDED
@@ -0,0 +1,277 @@
1
+ """GPU telemetry. NVML today; the interface is small so other vendors can plug in.
2
+
3
+ Everything here works without admin rights. Hardware counters (cache hit rate, per-kernel DRAM traffic) need
4
+ RmProfilingAdminOnly=0 or root and go through `nsys --gpu-metrics-devices` instead; see capabilities.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ctypes as C
10
+ import os
11
+
12
+ # Columns of one device in the fast sample.
13
+ G_MEM, G_POWER, G_ENERGY, G_SM, G_MEMCLK, G_UTIL, G_UTILMEM, G_THROTTLE, G_TEMP = range(9)
14
+
15
+ THROTTLE_BITS = {
16
+ 0x1: "idle",
17
+ 0x2: "app_clocks",
18
+ 0x4: "sw_power_cap",
19
+ 0x8: "hw_slowdown",
20
+ 0x10: "sync_boost",
21
+ 0x20: "sw_thermal",
22
+ 0x40: "hw_thermal",
23
+ 0x80: "hw_power_brake",
24
+ 0x100: "display_clocks",
25
+ }
26
+ # Reasons that mean the GPU was held below what the work wanted.
27
+ THROTTLE_BAD = 0x4 | 0x8 | 0x20 | 0x40 | 0x80
28
+ # Reasons that are real even on an idle GPU. The software power cap bit is not: idle GPUs report it with nothing to cap.
29
+ THROTTLE_HOT = 0x8 | 0x20 | 0x40 | 0x80
30
+
31
+
32
+ def throttle_names(bits, include_idle=False):
33
+ return [name for bit, name in THROTTLE_BITS.items() if (bits & bit) and (include_idle or bit != 0x1)]
34
+
35
+
36
+ class GpuBackend:
37
+ """No GPU telemetry: runs still get host metrics and the phase timeline."""
38
+
39
+ name = "none"
40
+
41
+ def __init__(self, reason="no backend"):
42
+ self.reason = reason
43
+ self.devices = []
44
+
45
+ def fast(self):
46
+ """Cheap gauges, per device: mem MiB, W, mJ (None here, see energy_mj), SM MHz, mem MHz, util %, mem util %, throttle bits, C."""
47
+ return []
48
+
49
+ def energy_mj(self, index):
50
+ """The device's cumulative energy counter. Can block far longer than the gauges, so it has its own thread."""
51
+ return None
52
+
53
+ def slow(self):
54
+ """Costlier reads, per device: {"procs": {pid: MiB}, "nvlink": (tx KiB, rx KiB) or None}."""
55
+ return []
56
+
57
+ def util_samples(self):
58
+ """Per device, new utilization samples as (wall ns, %). Empty when the backend keeps no history."""
59
+ return [[] for _ in self.devices]
60
+
61
+ def pcie_kbps(self, index, direction):
62
+ return None
63
+
64
+ def driver_version(self):
65
+ return None
66
+
67
+ def close(self):
68
+ pass
69
+
70
+
71
+ class _Memory(C.Structure):
72
+ _fields_ = [("total", C.c_ulonglong), ("free", C.c_ulonglong), ("used", C.c_ulonglong)]
73
+
74
+
75
+ class _Utilization(C.Structure):
76
+ _fields_ = [("gpu", C.c_uint), ("memory", C.c_uint)]
77
+
78
+
79
+ class _ProcessInfo(C.Structure):
80
+ _fields_ = [("pid", C.c_uint), ("used", C.c_ulonglong), ("gi", C.c_uint), ("ci", C.c_uint)]
81
+
82
+
83
+ class _FieldUnion(C.Union):
84
+ _fields_ = [("d", C.c_double), ("ui", C.c_uint), ("ul", C.c_ulong), ("ull", C.c_ulonglong), ("sll", C.c_longlong)]
85
+
86
+
87
+ class _Sample(C.Structure):
88
+ _fields_ = [("timeStamp", C.c_ulonglong), ("value", C.c_ulonglong)]
89
+
90
+
91
+ class _FieldValue(C.Structure):
92
+ _fields_ = [
93
+ ("fieldId", C.c_uint),
94
+ ("scopeId", C.c_uint),
95
+ ("timestamp", C.c_longlong),
96
+ ("latencyUsec", C.c_longlong),
97
+ ("valueType", C.c_int),
98
+ ("nvmlReturn", C.c_int),
99
+ ("value", _FieldUnion),
100
+ ]
101
+
102
+
103
+ _FI_NVLINK_TX = 138 # NVML_FI_DEV_NVLINK_THROUGHPUT_DATA_TX, KiB
104
+ _FI_NVLINK_RX = 139 # NVML_FI_DEV_NVLINK_THROUGHPUT_DATA_RX, KiB
105
+ _ALL_LINKS = 0xFFFFFFFF
106
+ _NOT_AVAILABLE = 0xFFFFFFFFFFFFFFFF
107
+ _MAX_PROCS = 256
108
+ _MIB = 1024 * 1024
109
+
110
+
111
+ class NvmlBackend(GpuBackend):
112
+ name = "nvml"
113
+
114
+ def __init__(self, library="libnvidia-ml.so.1"):
115
+ super().__init__(reason="")
116
+ self.lib = C.CDLL(library)
117
+ rc = self.lib.nvmlInit_v2()
118
+ if rc != 0:
119
+ raise OSError(f"nvmlInit_v2 returned {rc}")
120
+ self.handles = []
121
+ count = C.c_uint()
122
+ self.lib.nvmlDeviceGetCount_v2(C.byref(count))
123
+ for i in range(count.value):
124
+ handle = C.c_void_p()
125
+ if self.lib.nvmlDeviceGetHandleByIndex_v2(i, C.byref(handle)) != 0:
126
+ continue
127
+ self.handles.append(handle)
128
+ self.devices.append(self._describe(i, handle))
129
+
130
+ self._throttle = None
131
+ for fn in ("nvmlDeviceGetCurrentClocksEventReasons", "nvmlDeviceGetCurrentClocksThrottleReasons"):
132
+ if hasattr(self.lib, fn):
133
+ self._throttle = getattr(self.lib, fn)
134
+ break
135
+ self._procs = None
136
+ for fn in ("nvmlDeviceGetComputeRunningProcesses_v3", "nvmlDeviceGetComputeRunningProcesses_v2"):
137
+ if hasattr(self.lib, fn):
138
+ self._procs = getattr(self.lib, fn)
139
+ break
140
+ self._proc_buffer = (_ProcessInfo * _MAX_PROCS)()
141
+ self._mem = _Memory()
142
+ self._util = _Utilization()
143
+ self._uint = C.c_uint()
144
+ self._ull = C.c_ulonglong()
145
+ self._nvlink_ok = [self._probe_nvlink(h) for h in self.handles]
146
+ for dev, ok in zip(self.devices, self._nvlink_ok):
147
+ dev["nvlink"] = ok
148
+ self._samples = (_Sample * 512)()
149
+ self._util_seen = [0 for _ in self.handles]
150
+
151
+ def driver_version(self):
152
+ text = C.create_string_buffer(96)
153
+ if self.lib.nvmlSystemGetDriverVersion(text, 96) == 0:
154
+ return text.value.decode()
155
+ return None
156
+
157
+ def _describe(self, index, handle):
158
+ name = C.create_string_buffer(96)
159
+ uuid = C.create_string_buffer(96)
160
+ self.lib.nvmlDeviceGetName(handle, name, 96)
161
+ self.lib.nvmlDeviceGetUUID(handle, uuid, 96)
162
+ mem = _Memory()
163
+ self.lib.nvmlDeviceGetMemoryInfo(handle, C.byref(mem))
164
+ limit = C.c_uint()
165
+ power_limit = self.lib.nvmlDeviceGetEnforcedPowerLimit(handle, C.byref(limit)) == 0
166
+ return {
167
+ "index": index,
168
+ "name": name.value.decode(),
169
+ "uuid": uuid.value.decode(),
170
+ "memory_total_mib": mem.total // _MIB,
171
+ "power_limit_w": limit.value / 1000 if power_limit else None,
172
+ }
173
+
174
+ def _read_nvlink(self, handle):
175
+ fields = (_FieldValue * 2)()
176
+ fields[0].fieldId = _FI_NVLINK_TX
177
+ fields[1].fieldId = _FI_NVLINK_RX
178
+ fields[0].scopeId = fields[1].scopeId = _ALL_LINKS
179
+ if self.lib.nvmlDeviceGetFieldValues(handle, 2, fields) != 0:
180
+ return None
181
+ if fields[0].nvmlReturn != 0 or fields[1].nvmlReturn != 0:
182
+ return None
183
+ return (fields[0].value.ull, fields[1].value.ull)
184
+
185
+ def _probe_nvlink(self, handle):
186
+ return self._read_nvlink(handle) is not None
187
+
188
+ def _get_uint(self, fn, handle, *args):
189
+ return self._uint.value if fn(handle, *args, C.byref(self._uint)) == 0 else None
190
+
191
+ def energy_mj(self, index):
192
+ # Measured on 8 RTX A6000: 2.5 ms per GPU, about 1 us for the other queries. Read at 50 Hz on 8 GPUs it cost 3.4% of prefill speed
193
+ # and, worse, under-reported: 818 W where the power gauge said 1,395 W. About twice a second it agrees with the gauge (1,602 W against
194
+ # 1,591 W). So it is read slowly, and every report compares the two.
195
+ value = C.c_ulonglong()
196
+ if self.lib.nvmlDeviceGetTotalEnergyConsumption(self.handles[index], C.byref(value)) == 0:
197
+ return value.value
198
+ return None
199
+
200
+ def fast(self):
201
+ lib = self.lib
202
+ rows = []
203
+ for handle in self.handles:
204
+ used = self._mem.used // _MIB if lib.nvmlDeviceGetMemoryInfo(handle, C.byref(self._mem)) == 0 else None
205
+ power = self._get_uint(lib.nvmlDeviceGetPowerUsage, handle)
206
+ energy = None
207
+ sm_clock = self._get_uint(lib.nvmlDeviceGetClockInfo, handle, 1)
208
+ mem_clock = self._get_uint(lib.nvmlDeviceGetClockInfo, handle, 2)
209
+ if lib.nvmlDeviceGetUtilizationRates(handle, C.byref(self._util)) == 0:
210
+ util, util_mem = self._util.gpu, self._util.memory
211
+ else:
212
+ util = util_mem = None
213
+ throttle = None
214
+ if self._throttle is not None and self._throttle(handle, C.byref(self._ull)) == 0:
215
+ throttle = self._ull.value
216
+ temp = self._get_uint(lib.nvmlDeviceGetTemperature, handle, 0)
217
+ rows.append([used, power / 1000 if power is not None else None, energy, sm_clock, mem_clock, util, util_mem,
218
+ throttle, temp])
219
+ return rows
220
+
221
+ def slow(self):
222
+ rows = []
223
+ for handle, nvlink_ok in zip(self.handles, self._nvlink_ok):
224
+ procs = {}
225
+ if self._procs is not None:
226
+ count = C.c_uint(_MAX_PROCS)
227
+ if self._procs(handle, C.byref(count), self._proc_buffer) == 0:
228
+ for i in range(min(count.value, _MAX_PROCS)):
229
+ info = self._proc_buffer[i]
230
+ if info.used != _NOT_AVAILABLE:
231
+ procs[info.pid] = info.used // _MIB
232
+ rows.append({"procs": procs, "nvlink": self._read_nvlink(handle) if nvlink_ok else None})
233
+ return rows
234
+
235
+ def util_samples(self):
236
+ """New utilization samples per device since the last call, as (wall ns, %). The driver keeps a history at a finer
237
+ grain than the value a single query returns, so short phases are not judged by one stale reading."""
238
+ rows = []
239
+ for i, handle in enumerate(self.handles):
240
+ count = C.c_uint(len(self._samples))
241
+ value_type = C.c_int()
242
+ new = []
243
+ rc = self.lib.nvmlDeviceGetSamples(handle, 1, C.c_ulonglong(self._util_seen[i]), C.byref(value_type), C.byref(count),
244
+ self._samples) # NVML_GPU_UTILIZATION_SAMPLES
245
+ if rc == 0:
246
+ for k in range(count.value):
247
+ sample = self._samples[k]
248
+ # The value is a union, and a utilization only sets its low 32 bits.
249
+ new.append((sample.timeStamp * 1000, sample.value & 0xFFFFFFFF))
250
+ if new:
251
+ self._util_seen[i] = new[-1][0] // 1000
252
+ rows.append(new)
253
+ return rows
254
+
255
+ def pcie_kbps(self, index, direction):
256
+ """PCIe throughput in KB/s, direction 0 = GPU to host, 1 = host to GPU. The driver measures over 20 ms and blocks for
257
+ that long, so this only ever runs on its own thread, one query at a time."""
258
+ value = C.c_uint()
259
+ if self.lib.nvmlDeviceGetPcieThroughput(self.handles[index], direction, C.byref(value)) == 0:
260
+ return value.value
261
+ return None
262
+
263
+ def close(self):
264
+ try:
265
+ self.lib.nvmlShutdown()
266
+ except OSError:
267
+ pass
268
+
269
+
270
+ def open_backend():
271
+ """The best backend on this machine. Never raises: no GPU still gives a host-only profile."""
272
+ if os.environ.get("LLMPERFSCOPE_NO_GPU"):
273
+ return GpuBackend(reason="LLMPERFSCOPE_NO_GPU is set")
274
+ try:
275
+ return NvmlBackend()
276
+ except (OSError, AttributeError) as e:
277
+ return GpuBackend(reason=f"NVML not available: {e}")
llmperfscope/html.py ADDED
@@ -0,0 +1,320 @@
1
+ """One self-contained HTML page per run: the lifecycle on a shared time axis, with the diagnosis on top. No scripts, no network,
2
+ nothing to install, so the file can be mailed or attached to a pull request."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import html as html_lib
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from .gpu import G_MEM, G_SM, G_UTIL
11
+ from .sampler import H_ANON, H_FILE, H_STICKS, H_UTICKS, energy_series, load_ticks
12
+ from .system import SECTOR
13
+ from .procfs import CLK_TCK
14
+
15
+ WIDTH = 1120
16
+ PLOT_LEFT = 74
17
+ PLOT_RIGHT = 16
18
+ ROW_HEIGHT = 118
19
+ GROUP_COLORS = {
20
+ "startup": "#cbd5e1", "download": "#d8b4fe", "load": "#fcd34d", "setup": "#fdba74", "warmup": "#fda4af", "prefill": "#7dd3fc",
21
+ "decode": "#86efac", "serve": "#86efac", "run": "#86efac", "observe": "#86efac", "teardown": "#cbd5e1", "idle": "#e2e8f0",
22
+ }
23
+ SERIES_COLORS = ["#2563eb", "#dc2626", "#059669", "#d97706", "#7c3aed", "#0891b2", "#db2777", "#65a30d"]
24
+
25
+
26
+ def _escape(text):
27
+ return html_lib.escape(str(text))
28
+
29
+
30
+ def _downsample(points, limit=WIDTH):
31
+ if len(points) <= limit:
32
+ return points
33
+ step = len(points) / limit
34
+ return [points[int(i * step)] for i in range(limit)]
35
+
36
+
37
+ def _scale(values, floor=None):
38
+ """A round upper bound for an axis."""
39
+ top = max((v for v in values if v is not None), default=0)
40
+ if floor is not None:
41
+ top = max(top, floor)
42
+ if top <= 0:
43
+ return 1.0
44
+ magnitude = 10 ** (len(str(int(top))) - 1) if top >= 1 else 0.1
45
+ for factor in (1, 2, 2.5, 5, 10):
46
+ if top <= factor * magnitude:
47
+ return factor * magnitude
48
+ return top
49
+
50
+
51
+ def _format_axis(value):
52
+ if value >= 1000:
53
+ return f"{value / 1000:g}k"
54
+ return f"{value:g}"
55
+
56
+
57
+ def _chart(title, unit, series, phases, t_max, floor=None):
58
+ """series: [(label, [(t, value), ...])]. Colored bands behind the lines are the phases."""
59
+ all_values = [v for _, points in series for _, v in points]
60
+ if not all_values:
61
+ return ""
62
+ y_max = _scale(all_values, floor)
63
+ plot_width = WIDTH - PLOT_LEFT - PLOT_RIGHT
64
+ x = lambda t: PLOT_LEFT + plot_width * t / max(t_max, 1e-9) # noqa: E731
65
+ y = lambda v: 10 + (ROW_HEIGHT - 28) * (1 - min(v, y_max) / y_max) # noqa: E731
66
+ parts = [f'<svg viewBox="0 0 {WIDTH} {ROW_HEIGHT}" role="img" aria-label="{_escape(title)}">']
67
+ for phase in phases:
68
+ color = GROUP_COLORS.get(phase["group"])
69
+ if color and phase["group"] not in ("baseline", "tail"):
70
+ x0, x1 = x(phase["start_s"]), x(phase["end_s"])
71
+ parts.append(f'<rect x="{x0:.1f}" y="10" width="{max(x1 - x0, 0.5):.1f}" height="{ROW_HEIGHT - 28}" fill="{color}" opacity="0.14"/>')
72
+ for fraction in (0, 0.5, 1):
73
+ level = y(y_max * fraction)
74
+ parts.append(f'<line x1="{PLOT_LEFT}" x2="{WIDTH - PLOT_RIGHT}" y1="{level:.1f}" y2="{level:.1f}" stroke="var(--grid)" stroke-width="1"/>')
75
+ parts.append(f'<text x="{PLOT_LEFT - 8}" y="{level + 4:.1f}" text-anchor="end" class="tick">{_format_axis(y_max * fraction)}</text>')
76
+ parts.append(f'<text x="{PLOT_LEFT}" y="{ROW_HEIGHT - 6}" class="tick">{_escape(title)} ({_escape(unit)})</text>')
77
+ legend_x = WIDTH - PLOT_RIGHT
78
+ for index, (label, points) in reversed(list(enumerate(series))):
79
+ color = SERIES_COLORS[index % len(SERIES_COLORS)]
80
+ points = _downsample(points)
81
+ path = " ".join(f"{x(t):.1f},{y(v):.1f}" for t, v in points)
82
+ parts.append(f'<polyline points="{path}" fill="none" stroke="{color}" stroke-width="1.5"/>')
83
+ if len(series) > 1 or label:
84
+ parts.append(f'<text x="{legend_x}" y="{ROW_HEIGHT - 6}" text-anchor="end" class="tick" fill="{color}" style="fill:{color}">{_escape(label)}</text>')
85
+ legend_x -= 8 * len(str(label)) + 16
86
+ parts.append("</svg>")
87
+ return "".join(parts)
88
+
89
+
90
+ def _rates(ticks, getter, t0, min_dt_s=0.0):
91
+ """A cumulative counter as a rate per second. Counters that update in steps (the GPU energy counter, CPU ticks) need a window
92
+ wider than a step, or the rate jumps between zero and a multiple of its true value."""
93
+ out = []
94
+ previous = None
95
+ for tick in ticks:
96
+ value = getter(tick)
97
+ if value is None:
98
+ continue
99
+ if previous is None:
100
+ previous = (tick["t"], value)
101
+ continue
102
+ dt = (tick["t"] - previous[0]) / 1e9
103
+ if dt > 0 and dt >= min_dt_s:
104
+ out.append(((tick["t"] - t0) / 1e9, max(0.0, (value - previous[1]) / dt)))
105
+ previous = (tick["t"], value)
106
+ return out
107
+
108
+
109
+ def _energy_rate(ticks, device, t0):
110
+ """Average power between readings of the energy counter."""
111
+ times, joules = energy_series(ticks, device)
112
+ points = []
113
+ anchor = None
114
+ for t, value in zip(times, joules):
115
+ if anchor is None:
116
+ anchor = (t, value)
117
+ elif t - anchor[0] >= 300_000_000:
118
+ points.append(((t - t0) / 1e9, max(0.0, (value - anchor[1]) / ((t - anchor[0]) / 1e9))))
119
+ anchor = (t, value)
120
+ return points
121
+
122
+
123
+ def _table(headers, rows):
124
+ head = "".join(f"<th>{_escape(h)}</th>" for h in headers)
125
+ body = "".join("<tr>" + "".join(f"<td>{_escape(c)}</td>" for c in row) + "</tr>" for row in rows)
126
+ return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
127
+
128
+
129
+ def _gib(mib):
130
+ return "-" if mib is None else f"{mib / 1024:,.2f}"
131
+
132
+
133
+ def _value(value, digits=1):
134
+ if value is None:
135
+ return "-"
136
+ return f"{value:,.{digits}f}" if isinstance(value, float) else f"{value:,}"
137
+
138
+
139
+ def render(run, ticks):
140
+ phases = run.get("phases") or []
141
+ t0 = ticks[0]["t"] if ticks else 0
142
+ t_max = (ticks[-1]["t"] - t0) / 1e9 if ticks else 1.0
143
+ devices = run.get("devices_used") or []
144
+ out = []
145
+ title = run.get("label") or run.get("adapter") or "llmperfscope run"
146
+ out.append(f"<h1>{_escape(title)}</h1>")
147
+ facts = [f"status: {run.get('status')}", f"adapter: {run.get('adapter')}"]
148
+ host = (run.get("host") or {}).get("hostname")
149
+ if host:
150
+ facts.append(f"host: {host}")
151
+ before = run.get("machine_before") or {}
152
+ if "clean" in before:
153
+ facts.append("machine clean" if before["clean"] else "machine NOT clean")
154
+ if before.get("model_page_cache_fraction") is not None:
155
+ facts.append(f"model {before['model_page_cache_fraction'] * 100:.0f}% in page cache")
156
+ storage = before.get("model_storage") or {}
157
+ kinds = sorted({d.get("kind", "?") for d in storage.get("disks", [])})
158
+ if kinds:
159
+ facts.append(f"model on {'/'.join(kinds)} ({storage.get('fstype')})")
160
+ out.append(f'<p class="facts">{" · ".join(_escape(f) for f in facts)}</p>')
161
+ if run.get("perturbed"):
162
+ out.append('<p class="warn">Traced or sampled run: use it to find where time goes, not for speed numbers.</p>')
163
+
164
+ diagnosis = run.get("diagnosis") or {}
165
+ judged = {name: b for name, b in (diagnosis.get("bottlenecks") or {}).items() if b["confidence"] != "none"}
166
+ if judged:
167
+ out.append("<h2>What limits each phase</h2>")
168
+ out.append(_table(["phase", "limit", "confidence", "why"], [[n, b["limit"], b["confidence"], b["why"]] for n, b in judged.items()]))
169
+ for heading, key in (("Memory", "memory"), ("Data movement", "movement"), ("Serving", "serving"), ("GPU and host time", "timeline")):
170
+ findings = diagnosis.get(key) or []
171
+ if findings:
172
+ out.append(f"<h2>{heading}</h2><ul>")
173
+ for finding in sorted(findings, key=lambda f: f["severity"] != "warning"):
174
+ css = ' class="warn"' if finding["severity"] == "warning" else ""
175
+ out.append(f"<li{css}>{_escape(finding['text'])}</li>")
176
+ out.append("</ul>")
177
+
178
+ out.append("<h2>Lifecycle</h2>")
179
+ used_groups = []
180
+ for phase in phases:
181
+ if phase["group"] not in used_groups and phase["group"] in GROUP_COLORS:
182
+ used_groups.append(phase["group"])
183
+ legend = "".join(f'<span class="swatch" style="background:{GROUP_COLORS[g]}"></span>{_escape(g)} ' for g in used_groups)
184
+ out.append(f'<p class="legend">{legend}</p>')
185
+
186
+ if ticks:
187
+ t = lambda tick: (tick["t"] - t0) / 1e9 # noqa: E731
188
+ slow = [tick for tick in ticks if "s" in tick]
189
+ charts = []
190
+ if devices:
191
+ charts.append(_chart("GPU memory", "MiB", [(f"GPU {d}", [(t(k), k["g"][d][G_MEM] or 0) for k in ticks]) for d in devices], phases, t_max))
192
+ charts.append(_chart("GPU power", "W", [(f"GPU {d}", _energy_rate(ticks, d, t0)) for d in devices], phases, t_max))
193
+ charts.append(_chart("GPU utilization", "%", [(f"GPU {d}", _util_points(slow, d, ticks, t0)) for d in devices], phases, t_max, floor=100))
194
+ charts.append(_chart("GPU clock", "MHz", [(f"GPU {d}", [(t(k), k["g"][d][G_SM] or 0) for k in ticks]) for d in devices], phases, t_max))
195
+ charts.append(_chart("Host memory of the process", "GiB", [
196
+ ("anonymous", [(t(k), k["h"][H_ANON] / (1 << 20)) for k in ticks]),
197
+ ("file-backed", [(t(k), k["h"][H_FILE] / (1 << 20)) for k in ticks])], phases, t_max))
198
+ charts.append(_chart("Host CPU of the process", "cores", [("", _rates(ticks, lambda k: (k["h"][H_UTICKS] + k["h"][H_STICKS]) / CLK_TCK, t0, 0.25))], phases, t_max))
199
+ disks = sorted({name for k in slow for name in k["s"].get("sys", {}).get("disk", {})})
200
+ active = []
201
+ for name in disks:
202
+ read = _rates(slow, lambda k, n=name: (k["s"].get("sys", {}).get("disk", {}).get(n) or [None])[0], t0)
203
+ points = [(p[0], p[1] * SECTOR / (1 << 20)) for p in read]
204
+ if any(v > 0.5 for _, v in points):
205
+ kind = (run.get("disks") or {}).get(name, {}).get("kind", "disk")
206
+ active.append((f"{name} ({kind})", points))
207
+ if active:
208
+ charts.append(_chart("Disk reads, all processes", "MiB/s", active, phases, t_max))
209
+ pressure = []
210
+ for key, label in (("io_some", "I/O"), ("memory_some", "memory"), ("cpu_some", "CPU")):
211
+ points = [(p[0], min(100.0, p[1] / 1e4)) for p in _rates(slow, lambda k, c=key: k["s"].get("sys", {}).get("psi", {}).get(c), t0)]
212
+ if any(v > 1 for _, v in points):
213
+ pressure.append((label, points))
214
+ if pressure:
215
+ charts.append(_chart("Time tasks stalled, all processes", "%", pressure, phases, t_max, floor=100))
216
+ pcie = []
217
+ for direction, label in ((1, "RAM to GPU"), (0, "GPU to RAM")):
218
+ points = []
219
+ for k in slow:
220
+ rows = k["s"].get("pcie")
221
+ if rows:
222
+ values = [rows[d][direction] for d in devices if rows[d][direction] is not None]
223
+ if values:
224
+ points.append((t(k), sum(values) / 1024))
225
+ if points:
226
+ pcie.append((label, points))
227
+ if pcie:
228
+ charts.append(_chart("PCIe traffic, sampled", "MiB/s", pcie, phases, t_max))
229
+ nvlink = []
230
+ for d in devices:
231
+ points = _rates([k for k in slow if k["s"].get("nvlink") and k["s"]["nvlink"][d]], lambda k, d=d: k["s"]["nvlink"][d][0] / 1024, t0)
232
+ if any(v > 1 for _, v in points):
233
+ nvlink.append((f"GPU {d}", points))
234
+ if nvlink:
235
+ charts.append(_chart("NVLink traffic sent", "MiB/s", nvlink, phases, t_max))
236
+ out.extend(chart for chart in charts if chart)
237
+ ticks_text = "".join(f'<span style="left:{PLOT_LEFT / WIDTH * 100 + (WIDTH - PLOT_LEFT - PLOT_RIGHT) / WIDTH * 100 * s / max(t_max, 1e-9):.2f}%">{s:g} s</span>'
238
+ for s in _time_marks(t_max))
239
+ out.append(f'<div class="timeaxis">{ticks_text}</div>')
240
+
241
+ rows = []
242
+ for name, group in (run.get("groups") or {}).items():
243
+ if name in ("baseline", "tail") or group.get("wall_s", 0.0) < 0.005:
244
+ continue
245
+ gpu = group.get("gpu") or {}
246
+ host_row = group.get("host") or {}
247
+ rows.append([name, _value(group.get("wall_s"), 2), _value(group.get("tokens")), _gib(gpu.get("mem_end_mib")), _gib(gpu.get("mem_peak_mib")),
248
+ _gib(host_row.get("anon_peak_mib")), _gib(host_row.get("file_peak_mib")), _value(gpu.get("energy_j"), 0), _value(gpu.get("util_avg"), 0),
249
+ _value(host_row.get("busy_cores"), 2)])
250
+ if rows:
251
+ out.append("<h2>Phases</h2>")
252
+ out.append(_table(["phase", "wall s", "tokens", "GPU GiB end", "GPU GiB peak", "host anon GiB", "host file GiB", "GPU J", "GPU util %", "CPU cores"], rows))
253
+
254
+ throughput = run.get("throughput") or {}
255
+ energy = run.get("energy") or {}
256
+ rows = []
257
+ for name, entry in throughput.items():
258
+ if "tokens_per_s" in entry:
259
+ rows.append([name, _value(entry["tokens_per_s"], 2), _value(entry.get("stddev"), 2), _value(energy.get(f"{name}_j_per_token"), 3), "-", "-"])
260
+ elif "output_tokens_per_s" in entry:
261
+ rows.append([name, _value(entry.get("output_tokens_per_s"), 2), "-", _value(entry.get("j_per_output_token"), 3),
262
+ _value((entry.get("ttft_ms") or {}).get("p50"), 1), _value((entry.get("itl_ms") or {}).get("p50"), 1)])
263
+ if rows:
264
+ out.append("<h2>Speed</h2>")
265
+ out.append(_table(["", "tokens/s", "stddev", "GPU J/token", "first token ms p50", "between tokens ms p50"], rows))
266
+
267
+ return PAGE.format(title=_escape(title), body="\n".join(out))
268
+
269
+
270
+ def _util_points(slow, device, ticks, t0):
271
+ """The driver's utilization history when there is one, otherwise the instantaneous reading."""
272
+ points = []
273
+ for tick in slow:
274
+ rows = tick["s"].get("util")
275
+ if rows:
276
+ points += [((sample_t - t0) / 1e9, value) for sample_t, value in rows[device]]
277
+ if points:
278
+ return sorted(p for p in points if p[0] >= 0)
279
+ return [((tick["t"] - t0) / 1e9, tick["g"][device][G_UTIL] or 0) for tick in ticks]
280
+
281
+
282
+ def _time_marks(t_max):
283
+ step = _scale([t_max / 6])
284
+ marks = []
285
+ value = 0.0
286
+ while value <= t_max:
287
+ marks.append(round(value, 6))
288
+ value += step
289
+ return marks
290
+
291
+
292
+ def write(run_dir):
293
+ run_dir = Path(run_dir)
294
+ with open(run_dir / "run.json") as f:
295
+ run = json.load(f)
296
+ ticks = load_ticks(run_dir / "samples.jsonl.gz")
297
+ target = run_dir / "report.html"
298
+ target.write_text(render(run, ticks), encoding="utf-8")
299
+ return target
300
+
301
+
302
+ PAGE = """<!doctype html>
303
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
304
+ <title>{title}</title>
305
+ <style>
306
+ :root {{ --bg:#fff; --fg:#111827; --muted:#6b7280; --grid:#e5e7eb; --warn:#b91c1c; --head:#f3f4f6; }}
307
+ @media (prefers-color-scheme: dark) {{ :root {{ --bg:#0f172a; --fg:#e5e7eb; --muted:#94a3b8; --grid:#1f2937; --warn:#fca5a5; --head:#1e293b; }} }}
308
+ body {{ background:var(--bg); color:var(--fg); font:15px/1.5 -apple-system, "Segoe UI", Roboto, sans-serif; margin:0 auto; padding:24px 16px; max-width:1160px; }}
309
+ h1 {{ font-size:24px; margin:0 0 4px; }} h2 {{ font-size:17px; margin:28px 0 8px; }}
310
+ .facts {{ color:var(--muted); margin:0; }} .legend {{ color:var(--muted); margin:0 0 6px; font-size:13px; }}
311
+ .swatch {{ display:inline-block; width:12px; height:12px; border-radius:2px; margin:0 4px 0 10px; vertical-align:-1px; opacity:.75; }}
312
+ .warn {{ color:var(--warn); }} ul {{ padding-left:20px; margin:0; }} li {{ margin:4px 0; }}
313
+ table {{ border-collapse:collapse; width:100%; font-size:13.5px; display:block; overflow-x:auto; }} th, td {{ padding:5px 10px; text-align:left; border-bottom:1px solid var(--grid); }}
314
+ th {{ background:var(--head); font-weight:600; white-space:nowrap; }}
315
+ svg {{ display:block; width:100%; height:auto; }} .tick {{ font-size:11px; fill:var(--muted); }}
316
+ .timeaxis {{ position:relative; height:20px; font-size:11px; color:var(--muted); }} .timeaxis span {{ position:absolute; transform:translateX(-50%); white-space:nowrap; }}
317
+ </style></head><body>
318
+ {body}
319
+ </body></html>
320
+ """