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