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.
@@ -0,0 +1,1096 @@
1
+ """Figures for papers and reports: vector PDF and SVG, high-resolution PNG, the data behind each figure as CSV, and a suggested caption.
2
+
3
+ Made to go into a paper as they are. Fonts are embedded as TrueType (venues reject Type 3), sizes match one and two column widths,
4
+ colors stay distinct under color vision deficiency, and `--print-safe` adds line styles for grayscale print. Identical input gives
5
+ identical SVG and PDF, so a figure can be regenerated and diffed.
6
+
7
+ Needs matplotlib, which the measuring side of llmperfscope does not: pip install "llmperfscope[figures]".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ import json
14
+ import math
15
+ import re
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from . import __version__
20
+ from .gpu import G_MEM, G_POWER, G_SM, G_UTIL
21
+ from .procfs import CLK_TCK
22
+ from .sampler import H_ANON, H_FILE, H_STICKS, H_UTICKS, energy_series, load_ticks
23
+ from .system import SECTOR
24
+
25
+ SINGLE_COLUMN_IN = 3.5
26
+ DOUBLE_COLUMN_IN = 7.16
27
+
28
+ # Categorical order validated for color vision deficiency on a white surface; its order is part of what makes it safe, so it is
29
+ # assigned in sequence and never cycled.
30
+ SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]
31
+ SEQUENTIAL = ["#cde2fb", "#b7d3f6", "#9ec5f4", "#86b6ef", "#6da7ec", "#5598e7", "#3987e5", "#2a78d6", "#256abf", "#1c5cab", "#184f95", "#104281", "#0d366b"]
32
+ LINE_STYLES = ["-", "--", ":", "-."]
33
+ HATCHES = ["", "////", "\\\\\\\\", "xxxx", "", "////", "\\\\\\\\", "xxxx"]
34
+ PANEL_NAMES = {"gpu_memory": "GPU memory", "power": "GPU power", "util": "GPU utilization", "clock": "GPU clock", "host_memory": "host memory", "cpu": "host CPU",
35
+ "disk": "disk traffic", "stall": "task stalls", "pcie": "PCIe traffic", "nvlink": "NVLink traffic"}
36
+ GOOD, CRITICAL = "#0ca30c", "#d03b3b"
37
+ INK, INK2, MUTED, GRID, AXIS, BAND = "#0b0b0b", "#52514e", "#898781", "#e1e0d9", "#c3c2b7", "#f4f3ef"
38
+ NEUTRAL_FILL = "#c3c2b7"
39
+ TITLE_PAD = 4.0
40
+
41
+ PHASE_ORDER = ["startup", "run", "download", "load", "setup", "warmup", "prefill", "decode", "serve", "observe", "teardown", "idle"]
42
+ RUN_KINDS = ("lifecycle", "phases", "memory", "movement", "serving", "gpu-time", "hotspots")
43
+ COMPARE_KINDS = ("speed", "memory", "energy", "phases", "serving")
44
+ ALL_PANELS = ("gpu_memory", "power", "util", "clock", "host_memory", "cpu", "disk", "stall", "pcie", "nvlink")
45
+ DEFAULT_PANELS = ("gpu_memory", "power", "util", "host_memory", "cpu", "disk", "stall", "pcie", "nvlink")
46
+
47
+
48
+ @dataclass
49
+ class Style:
50
+ width: float = DOUBLE_COLUMN_IN
51
+ height: float = 0.0
52
+ formats: tuple = ("pdf", "png", "svg")
53
+ dpi: int = 300
54
+ font: str = "sans-serif"
55
+ font_size: float = 7.5
56
+ print_safe: bool = False
57
+ title: str = ""
58
+ letters: bool = False
59
+
60
+
61
+ def parse_width(value):
62
+ return {"single": SINGLE_COLUMN_IN, "double": DOUBLE_COLUMN_IN}.get(str(value), None) or float(value)
63
+
64
+
65
+ def _matplotlib():
66
+ try:
67
+ import matplotlib
68
+ except ImportError as error:
69
+ raise SystemExit('Figures need matplotlib: pip install "llmperfscope[figures]"') from error
70
+ matplotlib.use("Agg")
71
+ import matplotlib.pyplot as plt
72
+ return plt
73
+
74
+
75
+ def _rc(style):
76
+ size = style.font_size
77
+ return {
78
+ # TrueType in PDF and real text in SVG: venues reject Type 3 fonts, and text should stay selectable and editable.
79
+ "pdf.fonttype": 42, "ps.fonttype": 42, "svg.fonttype": "none", "svg.hashsalt": "llmperfscope",
80
+ "font.family": style.font,
81
+ "font.sans-serif": ["Arial", "Helvetica", "Liberation Sans", "DejaVu Sans"],
82
+ "font.serif": ["Times New Roman", "Times", "Liberation Serif", "DejaVu Serif"],
83
+ "font.size": size, "axes.titlesize": size, "axes.labelsize": size, "xtick.labelsize": size - 0.5, "ytick.labelsize": size - 0.5,
84
+ "legend.fontsize": size - 0.5, "figure.titlesize": size + 1.5,
85
+ "text.color": INK, "axes.labelcolor": INK2, "axes.titlecolor": INK, "xtick.labelcolor": INK2, "ytick.labelcolor": INK2,
86
+ "axes.edgecolor": AXIS, "axes.linewidth": 0.5, "xtick.color": AXIS, "ytick.color": AXIS,
87
+ "xtick.major.width": 0.5, "ytick.major.width": 0.5, "xtick.major.size": 2.0, "ytick.major.size": 0.0,
88
+ "xtick.major.pad": 2.0, "ytick.major.pad": 2.5, "axes.labelpad": 2.0, "axes.titlepad": TITLE_PAD,
89
+ "axes.spines.top": False, "axes.spines.right": False, "axes.titlelocation": "left",
90
+ "grid.color": GRID, "grid.linewidth": 0.4, "axes.axisbelow": True,
91
+ "lines.linewidth": 1.1, "lines.solid_capstyle": "round", "lines.solid_joinstyle": "round",
92
+ "legend.frameon": False, "legend.borderpad": 0.0, "legend.borderaxespad": 0.0, "legend.handlelength": 1.5, "legend.columnspacing": 1.0, "legend.handletextpad": 0.4,
93
+ "figure.constrained_layout.use": True, "figure.constrained_layout.h_pad": 0.02, "figure.constrained_layout.w_pad": 0.02,
94
+ "figure.constrained_layout.hspace": 0.03, "figure.constrained_layout.wspace": 0.04,
95
+ "figure.facecolor": "white", "axes.facecolor": "white", "savefig.bbox": "tight", "savefig.pad_inches": 0.03,
96
+ "path.simplify": True,
97
+ }
98
+
99
+
100
+ class Output:
101
+ """Writes figures and their data, and collects captions."""
102
+
103
+ def __init__(self, directory, style):
104
+ self.dir = Path(directory)
105
+ self.dir.mkdir(parents=True, exist_ok=True)
106
+ self.style = style
107
+ self.written = []
108
+ self.captions = []
109
+
110
+ def save(self, fig, stem, header, rows, caption):
111
+ software = f"llmperfscope {__version__}"
112
+ # The layout engine moves things a little on every draw. Fixed once, the files do not depend on which formats were asked for.
113
+ fig.canvas.draw()
114
+ fig.set_layout_engine("none")
115
+ for fmt in self.style.formats:
116
+ path = self.dir / f"{stem}.{fmt}"
117
+ # No dates in the files: the same run gives the same figure.
118
+ metadata = {"pdf": {"Creator": software, "Producer": software, "CreationDate": None},
119
+ "svg": {"Creator": software, "Date": None},
120
+ "png": {"Software": software}}.get(fmt)
121
+ fig.savefig(path, format=fmt, dpi=self.style.dpi if fmt == "png" else None, metadata=metadata)
122
+ self.written.append(path)
123
+ with open(self.dir / f"{stem}.csv", "w", newline="") as f:
124
+ writer = csv.writer(f)
125
+ writer.writerow(header)
126
+ writer.writerows(rows)
127
+ self.written.append(self.dir / f"{stem}.csv")
128
+ self.captions.append((stem, caption))
129
+
130
+ def finish(self, method):
131
+ lines = ["# Suggested captions", "", "Every number here comes from the run itself. Edit them into your own words.", ""]
132
+ for stem, caption in self.captions:
133
+ lines += [f"**{stem}**: {caption}", ""]
134
+ if method:
135
+ lines += ["## How it was measured", "", method, ""]
136
+ path = self.dir / "captions.md"
137
+ path.write_text("\n".join(lines))
138
+ self.written.append(path)
139
+
140
+
141
+ class RunData:
142
+ """One run directory, with its time axis starting when the process started."""
143
+
144
+ def __init__(self, path, label=None):
145
+ self.dir = Path(path)
146
+ with open(self.dir / "run.json") as f:
147
+ self.report = json.load(f)
148
+ self.label = label or self.report.get("label") or self.dir.name
149
+ self.ticks = load_ticks(self.dir / "samples.jsonl.gz")
150
+ self.devices = self.report.get("devices_used") or []
151
+ self.phases = self.report.get("phases") or []
152
+ first = self.ticks[0]["t"] if self.ticks else 0
153
+ self.t0 = self.report.get("t0_monotonic_ns") or first
154
+ started = [p["start_s"] for p in self.phases if p["group"] != "baseline"]
155
+ self.spawn_s = started[0] if started else 0.0
156
+ self.groups = self.report.get("groups") or {}
157
+
158
+ def seconds(self, t_ns):
159
+ return (t_ns - self.t0) / 1e9 - self.spawn_s
160
+
161
+ def slow(self):
162
+ return [tick for tick in self.ticks if "s" in tick]
163
+
164
+ def bands(self):
165
+ """Contiguous phases of one group, as (group, start, end). Zero-length phases do not break them up."""
166
+ out = []
167
+ for phase in self.phases:
168
+ group = phase["group"]
169
+ if group in ("baseline", "tail") or phase["end_s"] - phase["start_s"] < 0.001:
170
+ continue
171
+ start, end = phase["start_s"] - self.spawn_s, phase["end_s"] - self.spawn_s
172
+ if out and out[-1][0] == group:
173
+ out[-1] = (group, out[-1][1], end)
174
+ else:
175
+ out.append((group, start, end))
176
+ return out
177
+
178
+ def working_groups(self):
179
+ return [g for g in PHASE_ORDER if g in self.groups and self.groups[g].get("wall_s", 0.0) >= 0.005] + [
180
+ g for g in self.groups if g not in PHASE_ORDER and g not in ("baseline", "tail") and self.groups[g].get("wall_s", 0.0) >= 0.005]
181
+
182
+ def serve_levels(self):
183
+ out = [(name, t) for name, t in (self.report.get("throughput") or {}).items()
184
+ if isinstance(t, dict) and "concurrency" in t and t.get("output_tokens_per_s")]
185
+ return sorted(out, key=lambda item: item[1]["concurrency"])
186
+
187
+ def requests(self):
188
+ path = self.dir / "requests.jsonl"
189
+ rows = []
190
+ if path.exists():
191
+ with open(path) as f:
192
+ for line in f:
193
+ try:
194
+ rows.append(json.loads(line))
195
+ except ValueError:
196
+ pass
197
+ return rows
198
+
199
+ def hardware(self):
200
+ names = {}
201
+ for device in self.report.get("devices") or []:
202
+ if device.get("index") in self.devices:
203
+ names[device.get("name")] = names.get(device.get("name"), 0) + 1
204
+ return ", ".join(f"{count} x {name}" for name, count in names.items())
205
+
206
+
207
+ def _decimate(np, x, y, limit=1600):
208
+ """Keeps peaks: each bucket contributes its lowest and highest point. A plain stride would lose exactly the spikes worth seeing."""
209
+ if len(x) <= limit:
210
+ return x, y
211
+ edges = np.linspace(0, len(x), limit // 2 + 1, dtype=int)
212
+ keep = []
213
+ for a, b in zip(edges[:-1], edges[1:]):
214
+ if b > a:
215
+ segment = y[a:b]
216
+ keep += sorted({a + int(np.argmin(segment)), a + int(np.argmax(segment))})
217
+ return x[keep], y[keep]
218
+
219
+
220
+ def _rate(np, times, values, min_dt_s):
221
+ """A cumulative counter as a rate per second, over windows at least min_dt_s wide."""
222
+ xs, ys = [], []
223
+ anchor = None
224
+ for t, value in zip(times, values):
225
+ if value is None:
226
+ continue
227
+ if anchor is None:
228
+ anchor = (t, value)
229
+ elif t - anchor[0] >= min_dt_s:
230
+ xs.append((t + anchor[0]) / 2)
231
+ ys.append(max(0.0, (value - anchor[1]) / (t - anchor[0])))
232
+ anchor = (t, value)
233
+ return np.array(xs, dtype=float), np.array(ys, dtype=float)
234
+
235
+
236
+ def _panels(np, run, wanted, per_gpu):
237
+ """Each panel: key, y label, [(series label, x, y)], fixed top or None. Optional panels only appear when they have something to show."""
238
+ panels = {}
239
+ ticks = run.ticks
240
+ if not ticks:
241
+ return []
242
+ t = np.array([run.seconds(k["t"]) for k in ticks])
243
+ slow = run.slow()
244
+ slow_t = np.array([run.seconds(k["t"]) for k in slow])
245
+ devices = run.devices
246
+ count = len(devices)
247
+ suffix = f", {count} GPUs" if count > 1 else ""
248
+
249
+ if devices:
250
+ memory = np.array([[(k["g"][d][G_MEM] or 0) for d in devices] for k in ticks], dtype=float) / 1024
251
+ if per_gpu and count > 1:
252
+ panels["gpu_memory"] = ("gpu_memory", "GPU memory (GiB)", [(f"GPU {d}", t, memory[:, i]) for i, d in enumerate(devices)], None)
253
+ else:
254
+ panels["gpu_memory"] = ("gpu_memory", f"GPU memory{suffix} (GiB)", [("", t, memory.sum(axis=1))], None)
255
+
256
+ grid_end = t[-1]
257
+ power_series = []
258
+ grid = np.arange(t[0], grid_end, 0.5)
259
+ cumulative = np.zeros_like(grid)
260
+ have_energy = True
261
+ for d in devices:
262
+ times, joules = energy_series(ticks, d)
263
+ if len(times) < 2:
264
+ have_energy = False
265
+ break
266
+ seconds = np.array([run.seconds(x) for x in times])
267
+ joules = np.array(joules)
268
+ cumulative = cumulative + np.interp(grid, seconds, joules)
269
+ power_series.append((f"GPU {d}", (grid[1:] + grid[:-1]) / 2, np.maximum(0.0, np.diff(np.interp(grid, seconds, joules)) / np.diff(grid))))
270
+ if have_energy and len(grid) > 2:
271
+ if per_gpu and count > 1:
272
+ panels["power"] = ("power", "GPU power (W)", power_series, None)
273
+ else:
274
+ panels["power"] = ("power", f"GPU power{suffix} (W)", [("", (grid[1:] + grid[:-1]) / 2, np.maximum(0.0, np.diff(cumulative) / np.diff(grid)))], None)
275
+
276
+ if "power" not in panels:
277
+ gauge = np.array([[(k["g"][d][G_POWER] or 0) for d in devices] for k in ticks], dtype=float)
278
+ panels["power"] = ("power", f"GPU power{suffix} (W)", [("", t, gauge.sum(axis=1))], None)
279
+
280
+ history = {d: ([], []) for d in devices}
281
+ for tick in slow:
282
+ for d, samples in enumerate(tick["s"].get("util", [])):
283
+ if d in history:
284
+ for sample_t, value in samples:
285
+ history[d][0].append(run.seconds(sample_t))
286
+ history[d][1].append(value)
287
+ util_rows = []
288
+ for d in devices:
289
+ times, values = history[d]
290
+ if len(times) >= 2:
291
+ order = np.argsort(times)
292
+ util_rows.append(np.interp(t, np.array(times)[order], np.array(values, dtype=float)[order]))
293
+ else:
294
+ util_rows.append(np.array([(k["g"][d][G_UTIL] or 0) for k in ticks], dtype=float))
295
+ util_rows = np.array(util_rows)
296
+ if per_gpu and count > 1:
297
+ panels["util"] = ("util", "GPU utilization (%)", [(f"GPU {d}", t, util_rows[i]) for i, d in enumerate(devices)], 100.0)
298
+ else:
299
+ panels["util"] = ("util", f"GPU utilization{', mean' if count > 1 else ''} (%)", [("", t, util_rows.mean(axis=0))], 100.0)
300
+ clock = np.array([[(k["g"][d][G_SM] or 0) for d in devices] for k in ticks], dtype=float)
301
+ panels["clock"] = ("clock", "GPU clock (MHz)", [("", t, clock.mean(axis=1))], None)
302
+
303
+ panels["host_memory"] = ("host_memory", "Host memory (GiB)", [
304
+ ("anonymous", t, np.array([k["h"][H_ANON] for k in ticks]) / (1 << 20)),
305
+ ("file-backed", t, np.array([k["h"][H_FILE] for k in ticks]) / (1 << 20))], None)
306
+ x, y = _rate(np, t, [(k["h"][H_UTICKS] + k["h"][H_STICKS]) / CLK_TCK for k in ticks], 0.25)
307
+ if len(x):
308
+ panels["cpu"] = ("cpu", "Host CPU (cores)", [("", x, y)], None)
309
+
310
+ if len(slow) >= 2:
311
+ def system_rate(getter, scale=1.0, min_dt=0.4):
312
+ values = [getter(k["s"].get("sys") or {}) for k in slow]
313
+ x_rate, y_rate = _rate(np, slow_t, values, min_dt)
314
+ return x_rate, y_rate * scale
315
+
316
+ def disk_total(index):
317
+ def getter(row):
318
+ disks = row.get("disk") or {}
319
+ return sum(v[index] for v in disks.values()) if disks else None
320
+ return getter
321
+
322
+ mib = SECTOR / (1 << 20)
323
+ disk = []
324
+ for label, index in (("read", 0), ("written", 1)):
325
+ x, y = system_rate(disk_total(index), mib)
326
+ if len(y) and y.max() > 10.0:
327
+ disk.append((label, x, y))
328
+ if disk:
329
+ panels["disk"] = ("disk", "Disk, all processes (MiB/s)", disk, None)
330
+
331
+ stall = []
332
+ for label, key in (("I/O", "io_some"), ("memory", "memory_some"), ("CPU", "cpu_some")):
333
+ x, y = system_rate(lambda row, k=key: (row.get("psi") or {}).get(k), 100 / 1e6)
334
+ if len(y) and y.max() > 5.0:
335
+ stall.append((label, x, np.minimum(y, 100.0)))
336
+ if stall:
337
+ panels["stall"] = ("stall", "Tasks stalled, system (%)", stall, 100.0)
338
+
339
+ pcie = []
340
+ for label, direction in (("RAM to GPU", 1), ("GPU to RAM", 0)):
341
+ xs, ys = [], []
342
+ for tick, when in zip(slow, slow_t):
343
+ rows = tick["s"].get("pcie")
344
+ if rows:
345
+ values = [rows[d][direction] for d in devices if rows[d][direction] is not None]
346
+ if values:
347
+ xs.append(when)
348
+ ys.append(sum(values) / 1024)
349
+ if ys and max(ys) > 10:
350
+ pcie.append((label, np.array(xs), np.array(ys)))
351
+ if pcie:
352
+ panels["pcie"] = ("pcie", "PCIe, sampled (MiB/s)", pcie, None)
353
+
354
+ link_ticks = [k for k in slow if k["s"].get("nvlink")]
355
+ if len(link_ticks) >= 2 and devices:
356
+ when = np.array([run.seconds(k["t"]) for k in link_ticks])
357
+ sent = [sum((k["s"]["nvlink"][d] or [0, 0])[0] for d in devices) / 1024 for k in link_ticks]
358
+ x, y = _rate(np, when, sent, 0.4)
359
+ if len(y) and y.max() > 1.0:
360
+ panels["nvlink"] = ("nvlink", "GPU to GPU, NVLink (MiB/s)", [("", x, y)], None)
361
+ return [panels[key] for key in wanted if key in panels]
362
+
363
+
364
+ def _panel_grid(plt, count, style, row_height, narrow_columns=1):
365
+ """One row in a wide figure. In a narrow one, stacked: three panels side by side in 3.5 inches would run into each other. Returns the
366
+ figure, the axes, and whether each one carries the row labels."""
367
+ columns = count if style.width >= 5 else min(narrow_columns, count)
368
+ rows = math.ceil(count / columns)
369
+ fig, axes = plt.subplots(rows, columns, figsize=(style.width, style.height or rows * row_height), squeeze=False)
370
+ flat = list(axes.flatten())
371
+ for ax in flat[count:]:
372
+ ax.set_visible(False)
373
+ return fig, flat[:count], [i % columns == 0 for i in range(count)]
374
+
375
+
376
+ def _figure_legend(fig, handles, labels, where, columns):
377
+ """Above the panels. On saving, matplotlib anchors a figure's legend to the corner of the cropped image, not of the figure, so its own
378
+ margin is the only one it gets."""
379
+ fig.legend(handles, labels, loc=f"outside upper {where}", ncol=columns, borderaxespad=0.4)
380
+
381
+
382
+ def _letter(ax, index, style):
383
+ if style.letters:
384
+ # On the title's baseline. That clears a tick label sitting at the very top of the axis.
385
+ ax.annotate(f"({chr(97 + index)})", xy=(0, 1), xycoords="axes fraction", xytext=(-2.5, TITLE_PAD), textcoords="offset points", ha="right", va="bottom",
386
+ fontweight="bold", fontsize=style.font_size)
387
+
388
+
389
+ def _compact(value, _position=None):
390
+ """Clean axis numbers: 1,600 in full, 50K compact, one style per axis."""
391
+ if abs(value) >= 10000:
392
+ return f"{value / 1000:,g}K"
393
+ if abs(value) >= 1000:
394
+ return f"{value:,.0f}"
395
+ return f"{value:g}"
396
+
397
+
398
+ def lifecycle(plt, np, run, out, wanted=DEFAULT_PANELS, per_gpu=False, xlim=None):
399
+ style = out.style
400
+ panels = _panels(np, run, wanted, per_gpu)
401
+ if not panels:
402
+ return False
403
+ bands = run.bands()
404
+ per_panel = 0.8 if style.width > 5 else 0.68
405
+ height = style.height or (0.4 + per_panel * len(panels))
406
+ fig, axes = plt.subplots(len(panels) + 1, 1, sharex=True, figsize=(style.width, height), height_ratios=[0.12] + [1.0] * len(panels))
407
+ ribbon, axes = axes[0], list(axes[1:])
408
+ x_max = max((b[2] for b in bands), default=run.seconds(run.ticks[-1]["t"]))
409
+ x_range = tuple(xlim) if xlim else (0.0, x_max)
410
+
411
+ ribbon.set_axis_off()
412
+ ribbon.set_ylim(0, 1)
413
+ width_in = style.width * 0.9
414
+ span = max(x_range[1] - x_range[0], 1e-9)
415
+ for index, (group, start, end) in enumerate(bands):
416
+ ribbon.axvspan(start, end, color=BAND if index % 2 == 0 else "#ffffff", linewidth=0, zorder=0)
417
+ visible = max(0.0, min(end, x_range[1]) - max(start, x_range[0]))
418
+ needed_in = len(group) * style.font_size * 0.56 / 72
419
+ if visible / span * width_in >= needed_in:
420
+ middle = (max(start, x_range[0]) + min(end, x_range[1])) / 2
421
+ ribbon.text(middle, 0.45, group, ha="center", va="center", color=INK2, fontsize=style.font_size - 0.5)
422
+
423
+ rows = []
424
+ for index, (ax, (key, ylabel, series, top)) in enumerate(zip(axes, panels)):
425
+ for k, (group, start, end) in enumerate(bands):
426
+ if k % 2 == 0:
427
+ ax.axvspan(start, end, color=BAND, linewidth=0, zorder=0)
428
+ for k, (label, x, y) in enumerate(series):
429
+ for a, b in zip(x, y):
430
+ rows.append([key, label, round(float(a), 3), round(float(b), 3)])
431
+ px, py = _decimate(np, x, y)
432
+ linestyle = LINE_STYLES[k % len(LINE_STYLES)] if style.print_safe else "-"
433
+ ax.plot(px, py, color=SERIES[k % len(SERIES)], label=label or None, linestyle=linestyle, zorder=3)
434
+ peak = max((float(np.max(y)) for _, _, y in series if len(y)), default=1.0)
435
+ ax.set_ylim(0, top if top else max(peak * 1.12, 1e-6))
436
+ ax.yaxis.set_major_locator(plt.MaxNLocator(nbins=3, min_n_ticks=2))
437
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(_compact))
438
+ ax.grid(axis="y")
439
+ ax.spines["left"].set_visible(False)
440
+ ax.set_title(ylabel)
441
+ labelled = [s for s in series if s[0]]
442
+ if len(labelled) >= 2:
443
+ ax.legend(loc="lower right", bbox_to_anchor=(1.0, 1.02), ncol=len(labelled))
444
+ _letter(ax, index, style)
445
+ axes[-1].set_xlim(*x_range)
446
+ axes[-1].set_xlabel("Time since process start (s)")
447
+ if style.title:
448
+ fig.suptitle(style.title, x=0.0, ha="left")
449
+
450
+ names = [PANEL_NAMES[p[0]] for p in panels]
451
+ phase_text = ", ".join(f"{group} {run.groups[group]['wall_s']:.1f} s" for group in run.working_groups() if group not in ("idle", "startup"))
452
+ out.save(fig, "lifecycle", ["panel", "series", "time_s", "value"], rows,
453
+ f"Lifecycle of {run.label}{_on(run)}: {', '.join(names)}, from process start. Bands mark phases ({phase_text}).")
454
+ plt.close(fig)
455
+ return True
456
+
457
+
458
+ def _on(run):
459
+ hardware = run.hardware()
460
+ return f" on {hardware}" if hardware else ""
461
+
462
+
463
+ def _categories(ax, labels, show=True, rows=None):
464
+ """Rows from the top down. A chart with few rows keeps its bars thin instead of letting them fill the panel. With `rows`, panels side
465
+ by side get the same rows from the top, and so bars of the same thickness."""
466
+ ax.set_yticks(range(len(labels)), labels if show else [""] * len(labels))
467
+ if rows:
468
+ ax.set_ylim(rows - 0.5, -0.5)
469
+ return
470
+ extra = max(0.0, (4 - len(labels)) / 2)
471
+ ax.set_ylim(len(labels) - 0.5 + extra, -0.5 - extra)
472
+
473
+
474
+ def _log_ticks(axis, low, high):
475
+ """Numbers on a log axis even when it spans less than a decade, where the default would label nothing."""
476
+ from matplotlib.ticker import FuncFormatter, LogLocator, NullLocator
477
+ decades = math.log10(max(high, 1e-12) / max(low, 1e-12))
478
+ subs = (1.0,) if decades > 2 else (1.0, 2.0, 5.0) if decades > 0.8 else (1.0, 1.5, 2.0, 3.0, 5.0, 7.0)
479
+ axis.set_major_locator(LogLocator(base=10, subs=subs, numticks=12))
480
+ axis.set_major_formatter(FuncFormatter(_compact))
481
+ axis.set_minor_locator(NullLocator())
482
+
483
+
484
+ def _hbars(ax, labels, values, formatter, title, color=SERIES[0], errors=None, first=True):
485
+ """Horizontal bars with the value at the tip. Every value is labeled, so the value axis would only repeat it."""
486
+ positions = list(range(len(labels)))
487
+ numeric = [v if v is not None else 0.0 for v in values]
488
+ # One repetition has a standard deviation of zero, and a bar would end in a stray tick.
489
+ errors = errors if errors and any(errors) else None
490
+ bars = {"xerr": errors, "error_kw": {"elinewidth": 0.6, "ecolor": INK2, "capsize": 1.2, "capthick": 0.6}} if errors else {}
491
+ ax.barh(positions, numeric, height=0.62, color=color, linewidth=0, **bars)
492
+ _categories(ax, labels, first)
493
+ top = max(numeric + [v + (e or 0) for v, e in zip(numeric, errors or [0] * len(numeric))]) or 1.0
494
+ for position, value, number in zip(positions, values, numeric):
495
+ offset = (errors[position] if errors else 0.0) or 0.0
496
+ ax.text(number + offset + top * 0.02, position, "-" if value is None else formatter(value), va="center", ha="left", color=INK, fontsize=ax.xaxis.label.get_size() - 0.5)
497
+ ax.set_xlim(0, top * 1.28)
498
+ ax.set_title(title)
499
+ ax.xaxis.set_visible(False)
500
+ ax.spines["bottom"].set_visible(False)
501
+ ax.spines["left"].set_linewidth(0.6)
502
+
503
+
504
+ def _fmt(value, digits=None):
505
+ if value is None:
506
+ return "-"
507
+ if digits is not None:
508
+ return f"{value:,.{digits}f}"
509
+ if value >= 100:
510
+ return f"{value:,.0f}"
511
+ if value >= 10:
512
+ return f"{value:.1f}"
513
+ return f"{value:.2f}"
514
+
515
+
516
+ def phases_figure(plt, run, out):
517
+ style = out.style
518
+ groups = run.working_groups()
519
+ if not groups:
520
+ return False
521
+ metrics = [
522
+ ("Wall time (s)", lambda g: g.get("wall_s")),
523
+ ("GPU utilization (%)", lambda g: (g.get("gpu") or {}).get("util_avg")),
524
+ ("Host CPU (cores)", lambda g: (g.get("host") or {}).get("busy_cores")),
525
+ ("GPU energy (kJ)", lambda g: None if (g.get("gpu") or {}).get("energy_j") is None else g["gpu"]["energy_j"] / 1000),
526
+ ]
527
+ metrics = [m for m in metrics if any(m[1](run.groups[g]) is not None for g in groups)]
528
+ fig, axes, labelled = _panel_grid(plt, len(metrics), style, 0.32 + 0.17 * len(groups), narrow_columns=2)
529
+ rows = []
530
+ for index, (ax, (title, getter)) in enumerate(zip(axes, metrics)):
531
+ values = [getter(run.groups[g]) for g in groups]
532
+ _hbars(ax, groups, values, _fmt, title, first=labelled[index])
533
+ rows += [[title, g, v] for g, v in zip(groups, values)]
534
+ _letter(ax, index, style)
535
+ out.save(fig, "phases", ["metric", "phase", "value"], rows,
536
+ f"Per-phase summary of {run.label}{_on(run)}: wall time, GPU utilization, host CPU and GPU energy.")
537
+ plt.close(fig)
538
+ return True
539
+
540
+
541
+ def memory_figure(plt, run, out):
542
+ style = out.style
543
+ groups = [g for g in run.working_groups() if g != "idle"]
544
+ if not groups:
545
+ return False
546
+ memory = run.report.get("memory") or {}
547
+ columns = [
548
+ ("GPU memory, peak (GiB)", lambda g: None if (g.get("gpu") or {}).get("mem_peak_mib") is None else g["gpu"]["mem_peak_mib"] / 1024),
549
+ ("Host anonymous, peak (GiB)", lambda g: None if (g.get("host") or {}).get("anon_peak_mib") is None else g["host"]["anon_peak_mib"] / 1024),
550
+ ("Host file-backed, peak (GiB)", lambda g: None if (g.get("host") or {}).get("file_peak_mib") is None else g["host"]["file_peak_mib"] / 1024),
551
+ ]
552
+ columns = [c for c in columns if any(c[1](run.groups[g]) is not None for g in groups)]
553
+ breakdown = [row for row in memory.get("breakdown_mib") or [] if row.get("self")]
554
+ devices = (memory.get("gpu") or {}).get("devices") or []
555
+ extra_rows = 1 if breakdown or len(devices) > 1 else 0
556
+ wide = style.width >= 5
557
+ top_rows = 1 if wide else len(columns)
558
+ top_height = (0.28 + 0.17 * len(groups)) * top_rows
559
+ device_rows = max(len(breakdown), len(devices))
560
+ # Narrow: the per-GPU panel and the engine's table go one under the other too.
561
+ both = bool(breakdown) and len(devices) > 1
562
+ bottom_height = ((0.34 + 0.17 * device_rows) * (2 if both and not wide else 1)) if extra_rows else 0.0
563
+ fig = plt.figure(figsize=(style.width, style.height or top_height + bottom_height + 0.2))
564
+ grid = fig.add_gridspec(1 + extra_rows, 1, height_ratios=[top_height] + ([bottom_height] if extra_rows else []))
565
+ top = grid[0].subgridspec(top_rows, len(columns) if wide else 1)
566
+ rows = []
567
+ letter = 0
568
+ for index, (title, getter) in enumerate(columns):
569
+ ax = fig.add_subplot(top[0, index] if wide else top[index, 0])
570
+ values = [getter(run.groups[g]) for g in groups]
571
+ _hbars(ax, groups, values, _fmt, title, first=index == 0 or not wide)
572
+ rows += [[title, g, "", v] for g, v in zip(groups, values)]
573
+ _letter(ax, letter, style)
574
+ letter += 1
575
+ if extra_rows:
576
+ parts = (1 if len(devices) > 1 else 0) + (1 if breakdown else 0)
577
+ bottom = grid[1].subgridspec(1, parts) if wide else grid[1].subgridspec(parts, 1)
578
+ slot = 0
579
+ if len(devices) > 1:
580
+ ax = fig.add_subplot(bottom[0, slot] if wide else bottom[slot, 0])
581
+ slot += 1
582
+ labels = [f"GPU {d['index']}" for d in devices]
583
+ values = [d["peak_above_baseline_mib"] / 1024 for d in devices]
584
+ _hbars(ax, labels, values, _fmt, "Peak per GPU, above idle (GiB)")
585
+ rows += [["Peak per GPU, above idle (GiB)", label, "", v] for label, v in zip(labels, values)]
586
+ _letter(ax, letter, style)
587
+ letter += 1
588
+ if breakdown:
589
+ ax = fig.add_subplot(bottom[0, slot] if wide else bottom[slot, 0])
590
+ labels = [f"{r.get('name')} (per GPU)" if str(r.get("name", "")).startswith("Meta") else str(r.get("name")) for r in breakdown]
591
+ left = [0.0] * len(breakdown)
592
+ for k, part in enumerate(("model", "context", "compute")):
593
+ values = [(r.get(part) or 0) / 1024 for r in breakdown]
594
+ ax.barh(range(len(labels)), values, left=left, height=0.62, color=SERIES[k], edgecolor="white", linewidth=0.6, label=part, hatch=HATCHES[k] if style.print_safe else None)
595
+ rows += [["Engine's own table (GiB)", label, part, v] for label, v in zip(labels, values)]
596
+ left = [a + b for a, b in zip(left, values)]
597
+ _categories(ax, labels)
598
+ ax.set_xlim(0, (max(left) or 1.0) * 1.05)
599
+ ax.set_title("Engine's own table (GiB)")
600
+ ax.xaxis.set_major_locator(plt.MaxNLocator(nbins=4))
601
+ ax.grid(axis="x")
602
+ ax.spines["bottom"].set_visible(False)
603
+ ax.legend(loc="upper right", bbox_to_anchor=(1.0, -0.16), ncol=3)
604
+ _letter(ax, letter, style)
605
+ peak = (memory.get("gpu") or {}).get("peak_above_baseline_total_mib")
606
+ caption = f"Memory of {run.label}{_on(run)} by phase"
607
+ if peak:
608
+ caption += f". GPU memory peaks at {peak / 1024:.1f} GiB above idle over the GPUs in use"
609
+ out.save(fig, "memory", ["panel", "row", "part", "gib"], rows, caption + ".")
610
+ plt.close(fig)
611
+ return True
612
+
613
+
614
+ def _movement_rows(run, groups):
615
+ def disk(index):
616
+ def getter(group):
617
+ disks = (group.get("flows") or {}).get("disks") or {}
618
+ return sum(d.get(index, 0.0) for d in disks.values()) if disks else None
619
+ return getter
620
+
621
+ def flow(key):
622
+ return lambda group: (group.get("flows") or {}).get(key)
623
+
624
+ table = [
625
+ ("Disk to RAM, this process", lambda g: (g.get("host") or {}).get("disk_read_mib")),
626
+ ("Disk to RAM, all processes", disk("read_mib")),
627
+ ("RAM to disk, all processes", disk("write_mib")),
628
+ ("Swap in", flow("swap_in_mib")),
629
+ ("Swap out", flow("swap_out_mib")),
630
+ ("Page cache read again", flow("page_cache_refault_mib")),
631
+ ("RAM to GPU, sampled", flow("pcie_to_gpu_mib_est")),
632
+ ("GPU to RAM, sampled", flow("pcie_from_gpu_mib_est")),
633
+ ("GPU to GPU, NVLink", flow("nvlink_mib")),
634
+ ]
635
+ rows = []
636
+ for label, getter in table:
637
+ values = [getter(run.groups[g]) for g in groups]
638
+ if any((v or 0) >= 1.0 for v in values):
639
+ rows.append((label, values))
640
+ return rows
641
+
642
+
643
+ def _size(mib):
644
+ if mib is None or mib < 1.0:
645
+ return "-"
646
+ if mib >= 1024 * 100:
647
+ return f"{mib / 1024:,.0f} GiB"
648
+ if mib >= 1024:
649
+ return f"{mib / 1024:.1f} GiB"
650
+ return f"{mib:,.0f} MiB"
651
+
652
+
653
+ def movement_figure(plt, np, run, out):
654
+ style = out.style
655
+ groups = [g for g in run.working_groups() if g != "idle"]
656
+ rows = _movement_rows(run, groups)
657
+ if not rows:
658
+ return False
659
+ from matplotlib.colors import ListedColormap
660
+ matrix = np.array([[(v if v and v >= 1.0 else np.nan) for v in values] for _, values in rows], dtype=float)
661
+ scale = np.log10(np.maximum(matrix, 1.0))
662
+ top = np.nanmax(scale) or 1.0
663
+ fig, ax = plt.subplots(figsize=(style.width, style.height or 0.34 + 0.2 * len(rows)))
664
+ cmap = ListedColormap(SEQUENTIAL).with_extremes(bad="#ffffff")
665
+ ax.imshow(np.ma.masked_invalid(scale), aspect="auto", cmap=cmap, vmin=0, vmax=top)
666
+ ax.set_xticks(range(len(groups)), groups)
667
+ ax.xaxis.tick_top()
668
+ ax.xaxis.set_label_position("top")
669
+ ax.set_yticks(range(len(rows)), [label for label, _ in rows])
670
+ ax.tick_params(length=0)
671
+ for spine in ax.spines.values():
672
+ spine.set_visible(False)
673
+ ax.set_xticks(np.arange(-0.5, len(groups)), minor=True)
674
+ ax.set_yticks(np.arange(-0.5, len(rows)), minor=True)
675
+ ax.grid(which="minor", color="white", linewidth=1.2)
676
+ ax.tick_params(which="minor", length=0)
677
+ csv_rows = []
678
+ for i, (label, values) in enumerate(rows):
679
+ for j, value in enumerate(values):
680
+ csv_rows.append([label, groups[j], value])
681
+ fraction = (math.log10(max(value, 1.0)) / top) if value and value >= 1.0 else 0.0
682
+ ax.text(j, i, _size(value), ha="center", va="center", fontsize=style.font_size - 0.8, color="#ffffff" if fraction > 0.55 else (INK if value and value >= 1.0 else MUTED))
683
+ out.save(fig, "movement", ["flow", "phase", "mib"], csv_rows,
684
+ f"Data moved between tiers per phase for {run.label}{_on(run)}. Color is on a log scale. Disk and swap counters include other processes on the machine; "
685
+ "PCIe traffic is sampled about once a second and so is an estimate.")
686
+ plt.close(fig)
687
+ return True
688
+
689
+
690
+ def _cdf_axes(ax, title, xlabel):
691
+ ax.set_title(title)
692
+ ax.set_xscale("log")
693
+ ax.set_xlabel(xlabel)
694
+ ax.set_ylim(0, 1)
695
+ ax.yaxis.set_major_locator(_fixed([0, 0.5, 1.0]))
696
+ ax.grid(axis="both")
697
+ ax.spines["left"].set_visible(False)
698
+
699
+
700
+ def _fixed(values):
701
+ from matplotlib.ticker import FixedLocator
702
+ return FixedLocator(values)
703
+
704
+
705
+ def _plot_cdf(np, ax, values, color, label, linestyle="-"):
706
+ # Gaps can be zero when a server sends tokens in one write, and a log axis has no place for that.
707
+ ordered = np.sort(np.maximum(np.array(values, dtype=float), 1e-3))
708
+ ax.step(ordered, np.arange(1, len(ordered) + 1) / len(ordered), where="post", color=color, label=label, linestyle=linestyle)
709
+
710
+
711
+ def serving_figure(plt, np, run, out):
712
+ style = out.style
713
+ levels = run.serve_levels()
714
+ if not levels:
715
+ return False
716
+ requests = run.requests()
717
+ by_level = {}
718
+ for row in requests:
719
+ if row.get("level") is not None and row.get("ttft_s") is not None:
720
+ by_level.setdefault(row["level"], []).append(row)
721
+ have_raw = bool(by_level)
722
+ columns = 3 if have_raw else 1
723
+ # Without per-request data there is one short bar chart, not three panels to line up with.
724
+ fig, axes, _ = _panel_grid(plt, columns, style, (1.6 if style.width >= 5 else 1.3) if have_raw else 0.34 + 0.2 * len(levels))
725
+ rows = []
726
+ names = [f"{t['concurrency']}" for _, t in levels]
727
+ values = [t["output_tokens_per_s"] for _, t in levels]
728
+ _hbars(axes[0], names, values, _fmt, "Output tokens/s by concurrent clients")
729
+ rows += [["output_tokens_per_s", n, "", v] for n, v in zip(names, values)]
730
+ _letter(axes[0], 0, style)
731
+ if have_raw:
732
+ _cdf_axes(axes[1], "Time to first token, share of requests", "ms")
733
+ _cdf_axes(axes[2], "Time between tokens, share of gaps", "ms")
734
+ for k, (name, t) in enumerate(levels):
735
+ label = f"{t['concurrency']} client" + ("s" if t["concurrency"] > 1 else "")
736
+ linestyle = LINE_STYLES[k % len(LINE_STYLES)] if style.print_safe else "-"
737
+ ttft = [r["ttft_s"] * 1000 for r in by_level.get(t["concurrency"], [])]
738
+ gaps = [g * 1000 for r in by_level.get(t["concurrency"], []) for g in r.get("itl_s", [])]
739
+ if ttft:
740
+ _plot_cdf(np, axes[1], ttft, SERIES[k], label, linestyle)
741
+ rows += [["ttft_ms", t["concurrency"], i, round(v, 3)] for i, v in enumerate(ttft)]
742
+ if gaps:
743
+ _plot_cdf(np, axes[2], gaps, SERIES[k], label, linestyle)
744
+ rows += [["itl_ms", t["concurrency"], i, round(v, 3)] for i, v in enumerate(gaps)]
745
+ for ax in axes[1:]:
746
+ _log_ticks(ax.xaxis, *ax.get_xlim())
747
+ if len(levels) >= 2:
748
+ # Above the panels: where the curves run depends on the run, so no spot inside a panel is always free.
749
+ _figure_legend(fig, *axes[1].get_legend_handles_labels(), "right", len(levels))
750
+ _letter(axes[1], 1, style)
751
+ _letter(axes[2], 2, style)
752
+ first, last = levels[0][1], levels[-1][1]
753
+ caption = f"Serving {run.label}{_on(run)}: output throughput by number of concurrent clients"
754
+ if len(levels) >= 2:
755
+ caption += f" ({first['output_tokens_per_s']:,.0f} tokens/s at {first['concurrency']}, {last['output_tokens_per_s']:,.0f} at {last['concurrency']})"
756
+ if have_raw:
757
+ caption += ", and the distributions of time to first token and time between tokens as a client saw them"
758
+ out.save(fig, "serving", ["series", "clients", "index", "value"], rows, caption + ".")
759
+ plt.close(fig)
760
+ return True
761
+
762
+
763
+ def gpu_time_figure(plt, run, out):
764
+ style = out.style
765
+ groups = (run.report.get("nsys") or {}).get("groups") or {}
766
+ chosen = [g for g in ("prefill", "decode", "serve", "run") if groups.get(g, {}).get("devices")]
767
+ if not chosen:
768
+ return False
769
+ wide = style.width >= 5
770
+ fig = plt.figure(figsize=(style.width, style.height or sum(0.42 + 0.17 * max(len(groups[g]["devices"]), 6) for g in chosen) * (1 if wide else 2)))
771
+ grid = fig.add_gridspec(len(chosen), 2, width_ratios=[1.0, 0.9]) if wide else fig.add_gridspec(2 * len(chosen), 1)
772
+ rows = []
773
+ parts = (("kernels", "kernel_ms", SERIES[0]), ("collectives", "comm_ms", SERIES[1]), ("copies", "copy_ms", SERIES[2]), ("idle", "idle_ms", NEUTRAL_FILL))
774
+ for r, group in enumerate(chosen):
775
+ entry = groups[group]
776
+ ax = fig.add_subplot(grid[r, 0] if wide else grid[2 * r, 0])
777
+ devices = entry["devices"]
778
+ labels = [f"GPU {d['device']}" for d in devices]
779
+ left = [0.0] * len(devices)
780
+ for k, (name, key, color) in enumerate(parts):
781
+ shares = [(100 * d[key] / d["span_ms"]) if d["span_ms"] else 0.0 for d in devices]
782
+ ax.barh(range(len(devices)), shares, left=left, height=0.62, color=color, edgecolor="white", linewidth=0.6, label=name, hatch=HATCHES[k] if style.print_safe else None)
783
+ rows += [[group, label, name, round(v, 2)] for label, v in zip(labels, shares)]
784
+ left = [a + b for a, b in zip(left, shares)]
785
+ _categories(ax, labels)
786
+ ax.set_xlim(0, 100)
787
+ ax.set_title(f"{group}: share of each GPU's time (%)")
788
+ ax.xaxis.set_major_locator(_fixed([0, 25, 50, 75, 100]))
789
+ ax.grid(axis="x")
790
+ ax.spines["bottom"].set_visible(False)
791
+ if r == 0:
792
+ _figure_legend(fig, *ax.get_legend_handles_labels(), "left", 4)
793
+ _letter(ax, 2 * r, style)
794
+ top = entry.get("top_kernels", [])[:8]
795
+ ax2 = fig.add_subplot(grid[r, 1] if wide else grid[2 * r + 1, 0])
796
+ _hbars(ax2, [_short(k["name"], 30) for k in top], [100 * k["share"] for k in top], lambda v: f"{v:.0f}%", f"{group}: top kernels, share of kernel time")
797
+ rows += [[group, k["name"], "kernel share", round(100 * k["share"], 2)] for k in top]
798
+ _letter(ax2, 2 * r + 1, style)
799
+ out.save(fig, "gpu_time", ["phase", "row", "part", "percent"], rows,
800
+ f"How GPU time divides during {', '.join(chosen)} for {run.label}{_on(run)}, from a trace. Tracing slows the host, so idle reads higher than in an untraced run; "
801
+ "use this figure for where time goes, not for speed.")
802
+ plt.close(fig)
803
+ return True
804
+
805
+
806
+ def _short(name, limit):
807
+ return name if len(name) <= limit else name[:limit - 1] + "…"
808
+
809
+
810
+ def _function_label(name):
811
+ """Readable in a figure: no argument lists or symbol versions, and libraries without symbols named for what they are. The CSV keeps the
812
+ full names."""
813
+ name = re.sub(r"\(.*", "", name).split("@@")[0].split("@GLIBC")[0]
814
+ if name.endswith("!?"):
815
+ library = name[:-2]
816
+ for prefix, label in (("libcudart", "CUDA runtime"), ("libcuda", "GPU driver"), ("libnvidia", "GPU driver"), ("libnccl", "NCCL"), ("libc.so", "libc"), ("libstdc++", "libstdc++"), ("libgomp", "OpenMP runtime")):
817
+ if library.startswith(prefix):
818
+ return label
819
+ return re.sub(r"\.so.*", "", library)
820
+ if name == "[vdso]":
821
+ return "clock (vdso)"
822
+ return _short(name.lstrip("_"), 24)
823
+
824
+
825
+ def hotspots_figure(plt, run, out):
826
+ style = out.style
827
+ profile = (run.report.get("cpu_profile") or {}).get("groups") or {}
828
+ chosen = [g for g in ("load", "prefill", "decode", "serve", "run") if profile.get(g, {}).get("samples", 0) >= 20]
829
+ if not chosen:
830
+ return False
831
+ fig, axes, _ = _panel_grid(plt, len(chosen), style, 1.75 if style.width >= 5 else 1.35)
832
+ rows = []
833
+ tops = {group: [x for x in profile[group]["self"] if x["share"] >= 0.01][:7] for group in chosen}
834
+ most = max(len(top) for top in tops.values())
835
+ for index, (ax, group) in enumerate(zip(axes, chosen)):
836
+ top = tops[group]
837
+ labels = [_function_label(x["name"]) for x in top]
838
+ values = [100 * x["share"] for x in top]
839
+ ax.barh(range(len(top)), values, height=0.62, color=SERIES[0], linewidth=0)
840
+ _categories(ax, labels, rows=most)
841
+ peak = max(values) if values else 1.0
842
+ for position, x in enumerate(top):
843
+ ax.text(values[position] + peak * 0.04, position, f"{values[position]:.0f}%, {x.get('threads', 1)} thr", va="center", ha="left", fontsize=style.font_size - 0.8)
844
+ ax.set_xlim(0, peak * 2.0)
845
+ ax.set_title(f"{group}: {profile[group]['samples']:,} samples")
846
+ ax.xaxis.set_visible(False)
847
+ ax.spines["bottom"].set_visible(False)
848
+ rows += [[group, x["name"], round(100 * x["share"], 2), x.get("threads", 1), x.get("cpu_s"), x.get("pct_of_wall_on_busiest_thread")] for x in top]
849
+ _letter(ax, index, style)
850
+ out.save(fig, "hotspots", ["phase", "function", "percent_of_samples", "threads", "cpu_s", "percent_of_wall_on_busiest_thread"], rows,
851
+ f"Host CPU time by function for {run.label}{_on(run)}, from per-thread CPU-time sampling, with the number of threads (thr) that ran each function. "
852
+ "Sampling slows the run, so use this for where time goes, not for speed.")
853
+ plt.close(fig)
854
+ return True
855
+
856
+
857
+ def _method(run):
858
+ report = run.report
859
+ parts = [f"Measured with llmperfscope {__version__}"]
860
+ sampler = report.get("sampler") or {}
861
+ if sampler.get("hz_target"):
862
+ parts.append(f"sampling GPU gauges and process memory at {sampler['hz_target']:.0f} Hz and cumulative counters about twice a second")
863
+ hardware = run.hardware()
864
+ if hardware:
865
+ parts.append(f"on {hardware}")
866
+ if report.get("gpu_driver"):
867
+ parts.append(f"driver {report['gpu_driver']}")
868
+ text = ", ".join(parts) + "."
869
+ if report.get("perturbed"):
870
+ text += " This run was traced or sampled for detail; its speed is not representative."
871
+ before = report.get("machine_before") or {}
872
+ if before.get("model_page_cache_fraction") is not None:
873
+ text += f" {before['model_page_cache_fraction'] * 100:.0f}% of the model was in the page cache at the start."
874
+ command = report.get("identity", {})
875
+ if command.get("git_commit"):
876
+ text += f" Engine commit {command['git_commit'][:10]}."
877
+ if command.get("container_image"):
878
+ text += f" Container image {command['container_image']}."
879
+ return text
880
+
881
+
882
+ def make_run_figures(path, out_dir, style, kinds=RUN_KINDS, panels=DEFAULT_PANELS, per_gpu=False, xlim=None):
883
+ plt = _matplotlib()
884
+ import numpy as np
885
+ run = RunData(path)
886
+ out = Output(out_dir, style)
887
+ skipped = []
888
+ makers = {
889
+ "lifecycle": lambda: lifecycle(plt, np, run, out, panels, per_gpu, xlim),
890
+ "phases": lambda: phases_figure(plt, run, out),
891
+ "memory": lambda: memory_figure(plt, run, out),
892
+ "movement": lambda: movement_figure(plt, np, run, out),
893
+ "serving": lambda: serving_figure(plt, np, run, out),
894
+ "gpu-time": lambda: gpu_time_figure(plt, run, out),
895
+ "hotspots": lambda: hotspots_figure(plt, run, out),
896
+ }
897
+ with plt.rc_context(_rc(style)):
898
+ for kind in kinds:
899
+ if not makers[kind]():
900
+ skipped.append(kind)
901
+ out.finish(_method(run))
902
+ return out.written, skipped
903
+
904
+
905
+ def _compare_bars(plt, out, runs, stem, metrics, caption):
906
+ """Small multiples, one per metric, one bar per run. Metrics no run has are dropped."""
907
+ style = out.style
908
+ metrics = [m for m in metrics if any(m[1](r.report) is not None for r in runs)]
909
+ if not metrics:
910
+ return False
911
+ labels = [r.label for r in runs]
912
+ fig, axes, labelled = _panel_grid(plt, len(metrics), style, 0.34 + 0.2 * len(runs))
913
+ rows = []
914
+ for index, (ax, (title, value, error)) in enumerate(zip(axes, metrics)):
915
+ values = [value(r.report) for r in runs]
916
+ errors = [error(r.report) or 0.0 for r in runs] if error else None
917
+ _hbars(ax, labels, values, _fmt, title, errors=errors, first=labelled[index])
918
+ rows += [[title, label, v, (errors[i] if errors else "")] for i, (label, v) in enumerate(zip(labels, values))]
919
+ _letter(ax, index, style)
920
+ out.save(fig, stem, ["metric", "run", "value", "stddev"], rows, caption)
921
+ plt.close(fig)
922
+ return True
923
+
924
+
925
+ def _get(report, *keys):
926
+ node = report
927
+ for key in keys:
928
+ if not isinstance(node, dict) or key not in node:
929
+ return None
930
+ node = node[key]
931
+ return node
932
+
933
+
934
+ def _div(value, by):
935
+ return None if value is None else value / by
936
+
937
+
938
+ def compare_phases(plt, out, runs):
939
+ style = out.style
940
+ present = [g for g in PHASE_ORDER if g != "idle" and any(r.groups.get(g, {}).get("wall_s", 0.0) >= 0.005 for r in runs)]
941
+ if not present:
942
+ return False
943
+ # A phase keeps its color whichever runs are shown. Past eight, the smallest fold into "other".
944
+ order = [g for g in PHASE_ORDER if g in present][:8]
945
+ fig, ax = plt.subplots(figsize=(style.width, style.height or 0.5 + 0.2 * len(runs)))
946
+ left = [0.0] * len(runs)
947
+ rows = []
948
+ for k, group in enumerate(order):
949
+ values = [r.groups.get(group, {}).get("wall_s", 0.0) for r in runs]
950
+ ax.barh(range(len(runs)), values, left=left, height=0.62, color=SERIES[k], edgecolor="white", linewidth=0.6, label=group, hatch=HATCHES[k] if style.print_safe else None)
951
+ rows += [[r.label, group, v] for r, v in zip(runs, values)]
952
+ left = [a + b for a, b in zip(left, values)]
953
+ _categories(ax, [r.label for r in runs])
954
+ ax.set_xlabel("Wall time (s)")
955
+ ax.xaxis.set_major_locator(plt.MaxNLocator(nbins=6))
956
+ ax.grid(axis="x")
957
+ ax.spines["bottom"].set_visible(False)
958
+ ax.legend(loc="lower left", bbox_to_anchor=(0.0, 1.02), ncol=len(order))
959
+ out.save(fig, "compare_phases", ["run", "phase", "wall_s"], rows, f"Wall time by lifecycle phase for {', '.join(r.label for r in runs)}.")
960
+ plt.close(fig)
961
+ return True
962
+
963
+
964
+ def compare_serving(plt, np, out, runs):
965
+ style = out.style
966
+ series = [(r, r.serve_levels()) for r in runs]
967
+ series = [(r, levels) for r, levels in series if levels]
968
+ if not series:
969
+ return False
970
+ fig, axes, _ = _panel_grid(plt, 3, style, 1.9 if style.width >= 5 else 1.35)
971
+ rows = []
972
+ panels = (("Output tokens/s", lambda t: t.get("output_tokens_per_s"), False), ("Time to first token, median (ms)", lambda t: (t.get("ttft_ms") or {}).get("p50"), True),
973
+ ("Time between tokens, median (ms)", lambda t: (t.get("itl_ms") or {}).get("p50"), False))
974
+ for index, (ax, (title, getter, log)) in enumerate(zip(axes, panels)):
975
+ for k, (run, levels) in enumerate(series):
976
+ x = [t["concurrency"] for _, t in levels]
977
+ y = [getter(t) for _, t in levels]
978
+ linestyle = LINE_STYLES[k % len(LINE_STYLES)] if style.print_safe else "-"
979
+ ax.plot(x, y, color=SERIES[k], marker="o", markersize=3, markeredgecolor="white", markeredgewidth=0.5, label=run.label, linestyle=linestyle)
980
+ rows += [[title, run.label, a, b] for a, b in zip(x, y)]
981
+ ax.set_title(title)
982
+ ax.set_xlabel("Concurrent clients")
983
+ ticks = sorted({t["concurrency"] for _, levels in series for _, t in levels})
984
+ ax.xaxis.set_major_locator(_fixed(ticks))
985
+ if log:
986
+ ax.set_yscale("log")
987
+ _log_ticks(ax.yaxis, *ax.get_ylim())
988
+ else:
989
+ ax.set_ylim(bottom=0)
990
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(_compact))
991
+ ax.grid(axis="y")
992
+ ax.spines["left"].set_visible(False)
993
+ _letter(ax, index, style)
994
+ handles, labels = axes[0].get_legend_handles_labels()
995
+ if len(series) >= 2:
996
+ _figure_legend(fig, handles, labels, "left", len(series))
997
+ out.save(fig, "compare_serving", ["metric", "run", "clients", "value"], rows, f"Serving under increasing concurrency for {', '.join(r.label for r, _ in series)}, as a streaming client saw it.")
998
+ plt.close(fig)
999
+ return True
1000
+
1001
+
1002
+ def make_compare_figures(specs, out_dir, style, kinds=COMPARE_KINDS):
1003
+ """specs: [(label, run directory)]."""
1004
+ plt = _matplotlib()
1005
+ import numpy as np
1006
+ runs = [RunData(path, label) for label, path in specs]
1007
+ out = Output(out_dir, style)
1008
+ names = ", ".join(r.label for r in runs)
1009
+ makers = {
1010
+ "speed": lambda: _compare_bars(plt, out, runs, "compare_speed", [
1011
+ ("Prefill (tokens/s)", lambda r: _get(r, "throughput", "prefill", "tokens_per_s"), lambda r: _get(r, "throughput", "prefill", "stddev")),
1012
+ ("Decode (tokens/s)", lambda r: _get(r, "throughput", "decode", "tokens_per_s"), lambda r: _get(r, "throughput", "decode", "stddev"))],
1013
+ f"Speed of {names}. Error bars show the standard deviation over repetitions."),
1014
+ "memory": lambda: _compare_bars(plt, out, runs, "compare_memory", [
1015
+ ("GPU memory, peak above idle (GiB)", lambda r: _div(_get(r, "memory", "gpu", "peak_above_baseline_total_mib"), 1024), None),
1016
+ ("Host anonymous, peak (GiB)", lambda r: _div(_get(r, "memory", "host", "anon_peak_mib"), 1024), None),
1017
+ ("Host file-backed, peak (GiB)", lambda r: _div(_get(r, "memory", "host", "file_peak_mib"), 1024), None)],
1018
+ f"Peak memory of {names}."),
1019
+ "energy": lambda: _compare_bars(plt, out, runs, "compare_energy", [
1020
+ ("Prefill, GPU energy (J/token)", lambda r: _get(r, "energy", "prefill_j_per_token"), None),
1021
+ ("Decode, GPU energy (J/token)", lambda r: _get(r, "energy", "decode_j_per_token"), None),
1022
+ ("Serving, GPU energy (J/token)", lambda r: _get(r, "energy", "serve_j_per_token"), None)],
1023
+ f"GPU energy per token of {names}, from the GPUs' energy counters."),
1024
+ "phases": lambda: compare_phases(plt, out, runs),
1025
+ "serving": lambda: compare_serving(plt, np, out, runs),
1026
+ }
1027
+ skipped = []
1028
+ with plt.rc_context(_rc(style)):
1029
+ for kind in kinds:
1030
+ if not makers[kind]():
1031
+ skipped.append(kind)
1032
+ out.finish(_method(runs[0]) if runs else "")
1033
+ return out.written, skipped
1034
+
1035
+
1036
+ MATRIX_METRICS = [("prefill_tps", "Prefill speed"), ("decode_tps", "Decode speed"), ("gpu_peak_mib", "GPU memory"), ("host_anon_peak_mib", "Host memory"), ("load_s", "Load time"),
1037
+ ("decode_j_per_token", "Decode energy")]
1038
+
1039
+
1040
+ def make_matrix_figure(path, out_dir, style):
1041
+ """Per case and metric: B over A. Marker shape carries the verdict, so color is not the only channel."""
1042
+ plt = _matplotlib()
1043
+ with open(path) as f:
1044
+ table = json.load(f)
1045
+ cases = [c for c in table["cases"] if "metrics" in c]
1046
+ if not cases:
1047
+ raise SystemExit("this matrix has no finished cases")
1048
+ metrics = [(key, title) for key, title in MATRIX_METRICS if any(key in c["metrics"] for c in cases)]
1049
+ out = Output(out_dir, style)
1050
+ with plt.rc_context(_rc(style)):
1051
+ columns = len(metrics) if style.width >= 5 else min(2, len(metrics))
1052
+ grid_rows = math.ceil(len(metrics) / columns)
1053
+ fig, axes = plt.subplots(grid_rows, columns, sharey=True, squeeze=False, figsize=(style.width, style.height or (0.5 + 0.19 * len(cases)) * grid_rows + 0.3))
1054
+ axes = list(axes.flatten())
1055
+ for ax in axes[len(metrics):]:
1056
+ ax.set_visible(False)
1057
+ axes = axes[:len(metrics)]
1058
+ rows = []
1059
+ looks = {"better": ("^", GOOD, GOOD), "worse": ("v", CRITICAL, CRITICAL), "same": ("o", "white", MUTED)}
1060
+ for index, (ax, (key, title)) in enumerate(zip(axes, metrics)):
1061
+ ratios = []
1062
+ for i, case in enumerate(cases):
1063
+ value = case["metrics"].get(key)
1064
+ if not value or value.get("ratio") is None:
1065
+ continue
1066
+ ratio = value["ratio"]
1067
+ ratios.append(ratio)
1068
+ marker, face, edge = looks.get(value["verdict"], looks["same"])
1069
+ ax.scatter([ratio], [i], marker=marker, s=16, facecolor=face, edgecolor=edge, linewidth=0.7, zorder=3)
1070
+ rows.append([case["label"], key, value["a"], value["b"], ratio, value["verdict"]])
1071
+ ax.axvline(1.0, color=AXIS, linewidth=0.7, zorder=1)
1072
+ ax.set_title(title)
1073
+ furthest = max([abs(r - 1.0) for r in ratios] + [0.01])
1074
+ step = next((c for c in (0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5, 1.0, 2.0, 5.0) if c >= furthest), furthest)
1075
+ ax.set_xlim(max(0.0, 1.0 - step * 1.35), 1.0 + step * 1.35)
1076
+ ax.xaxis.set_major_locator(_fixed([max(0.0, 1.0 - step), 1.0, 1.0 + step]))
1077
+ ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:g}"))
1078
+ ax.grid(axis="y")
1079
+ ax.spines["left"].set_visible(False)
1080
+ _letter(ax, index, style)
1081
+ axes[0].set_yticks(range(len(cases)), [c["label"] for c in cases])
1082
+ axes[0].set_ylim(len(cases) - 0.5, -0.5)
1083
+ handles = [plt.Line2D([], [], linestyle="", marker=m, markerfacecolor=f, markeredgecolor=e, markersize=4, markeredgewidth=0.7, label=label)
1084
+ for label, (m, f, e) in (("better", looks["better"]), ("no real change", looks["same"]), ("worse", looks["worse"]))]
1085
+ _figure_legend(fig, handles, [handle.get_label() for handle in handles], "right", 3)
1086
+ fig.supxlabel(f"{table['b']} / {table['a']}", fontsize=style.font_size, color=INK2)
1087
+ counts = {"better": 0, "worse": 0}
1088
+ for row in rows:
1089
+ if row[5] in counts:
1090
+ counts[row[5]] += 1
1091
+ out.save(fig, "matrix", ["case", "metric", table["a"], table["b"], "ratio", "verdict"], rows,
1092
+ f"{table['b']} against {table['a']} over {len(cases)} cases, in alternating fresh processes ({table.get('order', 'ABBA')}). A result counts as better or worse only when the "
1093
+ f"arms do not overlap and the difference exceeds the noise floor; {counts['better']} results were better and {counts['worse']} worse.")
1094
+ plt.close(fig)
1095
+ out.finish("")
1096
+ return out.written, []