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/__init__.py +6 -0
- llmperfscope/__main__.py +5 -0
- llmperfscope/adapters.py +396 -0
- llmperfscope/analyze.py +598 -0
- llmperfscope/api.py +202 -0
- llmperfscope/capabilities.py +83 -0
- llmperfscope/cli.py +502 -0
- llmperfscope/compare.py +232 -0
- llmperfscope/container.py +79 -0
- llmperfscope/cpuprof.py +238 -0
- llmperfscope/diagnose.py +525 -0
- llmperfscope/driver.py +361 -0
- llmperfscope/figures.py +1096 -0
- llmperfscope/gpu.py +277 -0
- llmperfscope/html.py +320 -0
- llmperfscope/machine.py +313 -0
- llmperfscope/markers.py +107 -0
- llmperfscope/native/cpuprof.c +274 -0
- llmperfscope/nsys.py +287 -0
- llmperfscope/procfs.py +165 -0
- llmperfscope/report.py +264 -0
- llmperfscope/runner.py +567 -0
- llmperfscope/sampler.py +237 -0
- llmperfscope/share.py +67 -0
- llmperfscope/system.py +223 -0
- llmperfscope-0.1.0.dist-info/METADATA +207 -0
- llmperfscope-0.1.0.dist-info/RECORD +31 -0
- llmperfscope-0.1.0.dist-info/WHEEL +5 -0
- llmperfscope-0.1.0.dist-info/entry_points.txt +2 -0
- llmperfscope-0.1.0.dist-info/licenses/LICENSE +190 -0
- llmperfscope-0.1.0.dist-info/top_level.txt +1 -0
llmperfscope/figures.py
ADDED
|
@@ -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, []
|