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/analyze.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""Turns ticks and phases into per-phase numbers. Runs after the target has exited, so its cost does not matter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .gpu import G_MEM, G_MEMCLK, G_POWER, G_SM, G_TEMP, G_THROTTLE, G_UTIL, G_UTILMEM, THROTTLE_BAD, throttle_names
|
|
9
|
+
from .procfs import CLK_TCK
|
|
10
|
+
from .sampler import H_ANON, H_FILE, H_HWM, H_MAJFLT, H_MINFLT, H_PIN, H_SHMEM, H_STICKS, H_SWAP, H_THREADS, H_UTICKS, energy_series
|
|
11
|
+
from .system import SECTOR
|
|
12
|
+
|
|
13
|
+
PAGE_KB = 4
|
|
14
|
+
# The energy counter is read about twice a second, so energy per token means nothing over less time than this.
|
|
15
|
+
MIN_WALL_FOR_ENERGY_PER_TOKEN_S = 2.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _interp(times, values, t):
|
|
19
|
+
"""Value of a cumulative counter at time t. Clamps at the ends."""
|
|
20
|
+
if not times:
|
|
21
|
+
return None
|
|
22
|
+
i = bisect.bisect_left(times, t)
|
|
23
|
+
if i <= 0:
|
|
24
|
+
return values[0]
|
|
25
|
+
if i >= len(times):
|
|
26
|
+
return values[-1]
|
|
27
|
+
t0, t1 = times[i - 1], times[i]
|
|
28
|
+
if t1 == t0:
|
|
29
|
+
return values[i]
|
|
30
|
+
return values[i - 1] + (values[i] - values[i - 1]) * (t - t0) / (t1 - t0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _delta(times, values, t0, t1):
|
|
34
|
+
if not times:
|
|
35
|
+
return None
|
|
36
|
+
return _interp(times, values, t1) - _interp(times, values, t0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _kb_to_mib(kb):
|
|
40
|
+
return round(kb / 1024, 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Series:
|
|
44
|
+
"""Column access over the ticks of one run."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, ticks):
|
|
47
|
+
self.ticks = ticks
|
|
48
|
+
self.t = [tick["t"] for tick in ticks]
|
|
49
|
+
self.n_gpus = len(ticks[0]["g"]) if ticks else 0
|
|
50
|
+
self.energy = [energy_series(ticks, dev) for dev in range(self.n_gpus)]
|
|
51
|
+
self.cpu = {}
|
|
52
|
+
for key, col in (("user", H_UTICKS), ("sys", H_STICKS), ("majflt", H_MAJFLT), ("minflt", H_MINFLT)):
|
|
53
|
+
self.cpu[key] = [tick["h"][col] for tick in ticks]
|
|
54
|
+
slow = [tick for tick in ticks if "s" in tick]
|
|
55
|
+
self.slow_t = [tick["t"] for tick in slow]
|
|
56
|
+
self.slow = slow
|
|
57
|
+
self.io_t = [tick["t"] for tick in slow if "io" in tick["s"]]
|
|
58
|
+
self.io = [tick["s"]["io"] for tick in slow if "io" in tick["s"]]
|
|
59
|
+
self.nvlink_t = [tick["t"] for tick in slow if tick["s"].get("nvlink")]
|
|
60
|
+
self.nvlink = [tick["s"]["nvlink"] for tick in slow if tick["s"].get("nvlink")]
|
|
61
|
+
self.util = [([], []) for _ in range(self.n_gpus)]
|
|
62
|
+
for tick in slow:
|
|
63
|
+
for dev, samples in enumerate(tick["s"].get("util", [])):
|
|
64
|
+
for t, value in samples:
|
|
65
|
+
self.util[dev][0].append(t)
|
|
66
|
+
self.util[dev][1].append(value)
|
|
67
|
+
self.system = [tick["s"]["sys"] for tick in slow if tick["s"].get("sys")]
|
|
68
|
+
self.system_t = [tick["t"] for tick in slow if tick["s"].get("sys")]
|
|
69
|
+
|
|
70
|
+
def window(self, t0, t1):
|
|
71
|
+
"""Indices [i, j) of the ticks inside the window. Never empty if there are ticks: a phase shorter than the sample
|
|
72
|
+
period still gets the nearest tick for its gauges."""
|
|
73
|
+
i = bisect.bisect_left(self.t, t0)
|
|
74
|
+
j = bisect.bisect_left(self.t, t1)
|
|
75
|
+
if i >= len(self.t):
|
|
76
|
+
i = len(self.t) - 1
|
|
77
|
+
j = len(self.t)
|
|
78
|
+
if j <= i:
|
|
79
|
+
j = i + 1
|
|
80
|
+
return i, j
|
|
81
|
+
|
|
82
|
+
def system_delta(self, getter, t0, t1):
|
|
83
|
+
"""Change of a system counter over a window, or None when it was never read."""
|
|
84
|
+
times, values = [], []
|
|
85
|
+
for t, row in zip(self.system_t, self.system):
|
|
86
|
+
value = getter(row)
|
|
87
|
+
if value is not None:
|
|
88
|
+
times.append(t)
|
|
89
|
+
values.append(value)
|
|
90
|
+
return _delta(times, values, t0, t1) if times else None
|
|
91
|
+
|
|
92
|
+
def system_gauges(self, getter, t0, t1):
|
|
93
|
+
"""Readings of a system gauge inside a window; the nearest one when none falls inside."""
|
|
94
|
+
if not self.system_t:
|
|
95
|
+
return []
|
|
96
|
+
i = bisect.bisect_left(self.system_t, t0)
|
|
97
|
+
j = bisect.bisect_left(self.system_t, t1)
|
|
98
|
+
rows = self.system[i:j] or [self.system[min(i, len(self.system) - 1)]]
|
|
99
|
+
return [v for v in (getter(row) for row in rows) if v is not None]
|
|
100
|
+
|
|
101
|
+
def pcie_average(self, device, direction, t0, t1):
|
|
102
|
+
"""Average KB/s of the slow PCIe samples inside a window, or None when no sample fell inside."""
|
|
103
|
+
values = []
|
|
104
|
+
i = bisect.bisect_left(self.slow_t, t0)
|
|
105
|
+
j = bisect.bisect_left(self.slow_t, t1)
|
|
106
|
+
for tick in self.slow[i:j]:
|
|
107
|
+
rows = tick["s"].get("pcie")
|
|
108
|
+
if rows and rows[device][direction] is not None:
|
|
109
|
+
values.append(rows[device][direction])
|
|
110
|
+
return sum(values) / len(values) if values else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def used_devices(series, baseline_mem, min_above_baseline=64):
|
|
114
|
+
"""GPUs this run touched: any process of its tree holds memory there. When that is not visible (other PID namespace,
|
|
115
|
+
NVML without process info) fall back to memory growth over the baseline."""
|
|
116
|
+
used = set()
|
|
117
|
+
for tick in series.slow:
|
|
118
|
+
for dev, mib in enumerate(tick["s"].get("self_mib", [])):
|
|
119
|
+
if mib:
|
|
120
|
+
used.add(dev)
|
|
121
|
+
if used:
|
|
122
|
+
return sorted(used)
|
|
123
|
+
for dev in range(series.n_gpus):
|
|
124
|
+
peak = max((tick["g"][dev][G_MEM] or 0) for tick in series.ticks)
|
|
125
|
+
if peak - baseline_mem[dev] >= min_above_baseline:
|
|
126
|
+
used.add(dev)
|
|
127
|
+
return sorted(used)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _flows(series, t0, t1, devices, wall):
|
|
131
|
+
"""Bytes moved between tiers in a window: disk, RAM (swap, page cache), GPU (PCIe, NVLink), network."""
|
|
132
|
+
if not series.system:
|
|
133
|
+
return {}, {}, {}
|
|
134
|
+
flows = {}
|
|
135
|
+
disks = {}
|
|
136
|
+
names = set()
|
|
137
|
+
for row in series.system:
|
|
138
|
+
names |= set(row.get("disk", {}))
|
|
139
|
+
for name in sorted(names):
|
|
140
|
+
read = series.system_delta(lambda r, n=name: r.get("disk", {}).get(n, [None])[0], t0, t1)
|
|
141
|
+
write = series.system_delta(lambda r, n=name: (r.get("disk", {}).get(n) or [None, None])[1], t0, t1)
|
|
142
|
+
busy = series.system_delta(lambda r, n=name: (r.get("disk", {}).get(n) or [None, None, None])[2], t0, t1)
|
|
143
|
+
if read is None:
|
|
144
|
+
continue
|
|
145
|
+
if read > 0 or write > 0 or (busy or 0) > 0:
|
|
146
|
+
disks[name] = {
|
|
147
|
+
"read_mib": round(read * SECTOR / (1 << 20), 1),
|
|
148
|
+
"write_mib": round((write or 0) * SECTOR / (1 << 20), 1),
|
|
149
|
+
"busy_pct": round(min(100.0, (busy or 0) / (wall * 1000) * 100), 1) if wall > 0 else None,
|
|
150
|
+
}
|
|
151
|
+
if disks:
|
|
152
|
+
flows["disks"] = disks
|
|
153
|
+
|
|
154
|
+
def vm(key):
|
|
155
|
+
return series.system_delta(lambda r: r.get("vm", {}).get(key), t0, t1)
|
|
156
|
+
|
|
157
|
+
swap_in, swap_out = vm("pswpin"), vm("pswpout")
|
|
158
|
+
if swap_in is not None:
|
|
159
|
+
flows["swap_in_mib"] = round(swap_in * PAGE_KB / 1024, 1)
|
|
160
|
+
flows["swap_out_mib"] = round((swap_out or 0) * PAGE_KB / 1024, 1)
|
|
161
|
+
refault = vm("workingset_refault_file")
|
|
162
|
+
if refault is not None:
|
|
163
|
+
flows["page_cache_refault_mib"] = round(refault * PAGE_KB / 1024, 1)
|
|
164
|
+
scanned = vm("pgscan_direct")
|
|
165
|
+
if scanned is not None:
|
|
166
|
+
flows["direct_reclaim_mib"] = round(scanned * PAGE_KB / 1024, 1)
|
|
167
|
+
cached = series.system_gauges(lambda r: r.get("mem", {}).get("Cached"), t0, t1)
|
|
168
|
+
if cached:
|
|
169
|
+
flows["page_cache_change_mib"] = cached[-1] - cached[0]
|
|
170
|
+
available = series.system_gauges(lambda r: r.get("mem", {}).get("MemAvailable"), t0, t1)
|
|
171
|
+
if available:
|
|
172
|
+
flows["mem_available_min_mib"] = min(available)
|
|
173
|
+
swap_free = series.system_gauges(lambda r: r.get("mem", {}).get("SwapFree"), t0, t1)
|
|
174
|
+
swap_total = series.system_gauges(lambda r: r.get("mem", {}).get("SwapTotal"), t0, t1)
|
|
175
|
+
if swap_free and swap_total:
|
|
176
|
+
flows["swap_used_max_mib"] = swap_total[-1] - min(swap_free)
|
|
177
|
+
rx = series.system_delta(lambda r: (r.get("net") or [None])[0], t0, t1)
|
|
178
|
+
tx = series.system_delta(lambda r: (r.get("net") or [None, None])[1], t0, t1)
|
|
179
|
+
if rx is not None:
|
|
180
|
+
flows["network_rx_mib"] = round(rx / (1 << 20), 1)
|
|
181
|
+
flows["network_tx_mib"] = round((tx or 0) / (1 << 20), 1)
|
|
182
|
+
to_gpu = from_gpu = None
|
|
183
|
+
for dev in devices:
|
|
184
|
+
value = series.pcie_average(dev, 1, t0, t1)
|
|
185
|
+
if value is not None:
|
|
186
|
+
to_gpu = (to_gpu or 0.0) + value * wall / 1024
|
|
187
|
+
value = series.pcie_average(dev, 0, t0, t1)
|
|
188
|
+
if value is not None:
|
|
189
|
+
from_gpu = (from_gpu or 0.0) + value * wall / 1024
|
|
190
|
+
# Estimates: PCIe traffic is sampled, not counted.
|
|
191
|
+
if to_gpu is not None:
|
|
192
|
+
flows["pcie_to_gpu_mib_est"] = round(to_gpu, 1)
|
|
193
|
+
if from_gpu is not None:
|
|
194
|
+
flows["pcie_from_gpu_mib_est"] = round(from_gpu, 1)
|
|
195
|
+
|
|
196
|
+
pressure = {}
|
|
197
|
+
for key in ("cpu_some", "memory_some", "memory_full", "io_some", "io_full"):
|
|
198
|
+
value = series.system_delta(lambda r, k=key: r.get("psi", {}).get(k), t0, t1)
|
|
199
|
+
if value is not None and wall > 0:
|
|
200
|
+
pressure[key + "_pct"] = round(min(100.0, value / (wall * 1e6) * 100), 1)
|
|
201
|
+
|
|
202
|
+
system_cpu = {}
|
|
203
|
+
first = series.system_gauges(lambda r: r.get("cpu"), t0, t1)
|
|
204
|
+
if len(series.system_t) >= 2:
|
|
205
|
+
deltas = [series.system_delta(lambda r, c=c: (r.get("cpu") or [None] * 8)[c], t0, t1) for c in range(8)]
|
|
206
|
+
if first and all(d is not None for d in deltas):
|
|
207
|
+
total = sum(deltas)
|
|
208
|
+
if total > 0:
|
|
209
|
+
system_cpu = {
|
|
210
|
+
"busy_pct": round((deltas[0] + deltas[1] + deltas[2] + deltas[5] + deltas[6]) / total * 100, 1),
|
|
211
|
+
"iowait_pct": round(deltas[4] / total * 100, 2),
|
|
212
|
+
"steal_pct": round(deltas[7] / total * 100, 2),
|
|
213
|
+
}
|
|
214
|
+
return flows, pressure, system_cpu
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def phase_metrics(series, t0, t1, devices, baseline_mem):
|
|
218
|
+
i, j = series.window(t0, t1)
|
|
219
|
+
ticks = series.ticks[i:j]
|
|
220
|
+
wall = (t1 - t0) / 1e9
|
|
221
|
+
out = {"wall_s": round(wall, 6)}
|
|
222
|
+
if not ticks:
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
rows = []
|
|
226
|
+
total_j = 0.0
|
|
227
|
+
have_energy = bool(devices)
|
|
228
|
+
for dev in devices:
|
|
229
|
+
values = [tick["g"][dev] for tick in ticks]
|
|
230
|
+
mem = [v[G_MEM] for v in values if v[G_MEM] is not None]
|
|
231
|
+
energy = _delta(*series.energy[dev], t0, t1)
|
|
232
|
+
if energy is None:
|
|
233
|
+
have_energy = False
|
|
234
|
+
else:
|
|
235
|
+
total_j += energy
|
|
236
|
+
sm = [v[G_SM] for v in values if v[G_SM] is not None]
|
|
237
|
+
# The driver's own history when it has samples in this window, else the instantaneous reading.
|
|
238
|
+
times, history = series.util[dev]
|
|
239
|
+
a, b = bisect.bisect_left(times, t0), bisect.bisect_left(times, t1)
|
|
240
|
+
util = history[a:b] or [v[G_UTIL] for v in values if v[G_UTIL] is not None]
|
|
241
|
+
util_mem = [v[G_UTILMEM] for v in values if v[G_UTILMEM] is not None]
|
|
242
|
+
power = [v[G_POWER] for v in values if v[G_POWER] is not None]
|
|
243
|
+
throttle = 0
|
|
244
|
+
throttled_ticks = 0
|
|
245
|
+
for v in values:
|
|
246
|
+
bits = v[G_THROTTLE] or 0
|
|
247
|
+
throttle |= bits
|
|
248
|
+
if bits & THROTTLE_BAD:
|
|
249
|
+
throttled_ticks += 1
|
|
250
|
+
rows.append({
|
|
251
|
+
"index": dev,
|
|
252
|
+
"mem_start_mib": mem[0] if mem else None,
|
|
253
|
+
"mem_end_mib": mem[-1] if mem else None,
|
|
254
|
+
"mem_peak_mib": max(mem) if mem else None,
|
|
255
|
+
"energy_j": round(energy, 3) if energy is not None else None,
|
|
256
|
+
"power_avg_w": round(energy / wall, 2) if energy is not None and wall > 0 else (
|
|
257
|
+
round(sum(power) / len(power), 2) if power else None),
|
|
258
|
+
"power_gauge_w": round(sum(power) / len(power), 2) if power else None,
|
|
259
|
+
"sm_mhz_avg": round(sum(sm) / len(sm)) if sm else None,
|
|
260
|
+
"sm_mhz_min": min(sm) if sm else None,
|
|
261
|
+
"mem_mhz_avg": round(sum(v[G_MEMCLK] or 0 for v in values) / len(values)),
|
|
262
|
+
"util_avg": round(sum(util) / len(util), 1) if util else None,
|
|
263
|
+
"util_mem_avg": round(sum(util_mem) / len(util_mem), 1) if util_mem else None,
|
|
264
|
+
"temp_c_max": max((v[G_TEMP] or 0) for v in values),
|
|
265
|
+
"throttle": throttle_names(throttle),
|
|
266
|
+
# A stray tick is not a limit: a phase counts as throttled when it spent a real part of its time that way.
|
|
267
|
+
"throttled": throttled_ticks >= max(2, 0.1 * len(values)),
|
|
268
|
+
"throttled_pct": round(100 * throttled_ticks / len(values), 1),
|
|
269
|
+
})
|
|
270
|
+
if devices:
|
|
271
|
+
sums = [sum(tick["g"][dev][G_MEM] or 0 for dev in devices) for tick in ticks]
|
|
272
|
+
baseline = sum(baseline_mem[dev] for dev in devices)
|
|
273
|
+
utils = [r["util_avg"] for r in rows if r["util_avg"] is not None]
|
|
274
|
+
out["gpu"] = {
|
|
275
|
+
"mem_start_mib": sums[0] - baseline,
|
|
276
|
+
"mem_end_mib": sums[-1] - baseline,
|
|
277
|
+
"mem_peak_mib": max(sums) - baseline,
|
|
278
|
+
"mem_peak_max_device_mib": max((r["mem_peak_mib"] or 0) - baseline_mem[r["index"]] for r in rows),
|
|
279
|
+
"energy_j": round(total_j, 3) if have_energy else None,
|
|
280
|
+
"power_avg_w": round(total_j / wall, 2) if have_energy and wall > 0 else None,
|
|
281
|
+
"power_gauge_w": round(sum(r["power_gauge_w"] or 0 for r in rows), 2),
|
|
282
|
+
"sm_mhz_avg": _avg(rows, "sm_mhz_avg"),
|
|
283
|
+
"sm_mhz_min": min((r["sm_mhz_min"] for r in rows if r["sm_mhz_min"] is not None), default=None),
|
|
284
|
+
"util_avg": _avg(rows, "util_avg"),
|
|
285
|
+
"util_min_device": min(utils) if utils else None,
|
|
286
|
+
"util_max_device": max(utils) if utils else None,
|
|
287
|
+
"util_mem_avg": _avg(rows, "util_mem_avg"),
|
|
288
|
+
"temp_c_max": max(r["temp_c_max"] for r in rows),
|
|
289
|
+
"throttled": any(r["throttled"] for r in rows),
|
|
290
|
+
"throttle": sorted({name for r in rows for name in r["throttle"]}),
|
|
291
|
+
"devices": rows,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
anon = [tick["h"][H_ANON] for tick in ticks]
|
|
295
|
+
file_ = [tick["h"][H_FILE] for tick in ticks]
|
|
296
|
+
shmem = [tick["h"][H_SHMEM] for tick in ticks]
|
|
297
|
+
host = {
|
|
298
|
+
"anon_start_mib": _kb_to_mib(anon[0]),
|
|
299
|
+
"anon_end_mib": _kb_to_mib(anon[-1]),
|
|
300
|
+
"anon_peak_mib": _kb_to_mib(max(anon)),
|
|
301
|
+
"file_start_mib": _kb_to_mib(file_[0]),
|
|
302
|
+
"file_end_mib": _kb_to_mib(file_[-1]),
|
|
303
|
+
"file_peak_mib": _kb_to_mib(max(file_)),
|
|
304
|
+
"shmem_peak_mib": _kb_to_mib(max(shmem)),
|
|
305
|
+
"pinned_peak_mib": _kb_to_mib(max(tick["h"][H_PIN] for tick in ticks)),
|
|
306
|
+
"swap_peak_mib": _kb_to_mib(max(tick["h"][H_SWAP] for tick in ticks)),
|
|
307
|
+
"threads_max": max(tick["h"][H_THREADS] for tick in ticks),
|
|
308
|
+
"user_s": round(_delta(series.t, series.cpu["user"], t0, t1) / CLK_TCK, 3),
|
|
309
|
+
"sys_s": round(_delta(series.t, series.cpu["sys"], t0, t1) / CLK_TCK, 3),
|
|
310
|
+
"major_faults": round(_delta(series.t, series.cpu["majflt"], t0, t1)),
|
|
311
|
+
}
|
|
312
|
+
host["busy_cores"] = round((host["user_s"] + host["sys_s"]) / wall, 2) if wall > 0 else None
|
|
313
|
+
if series.io_t:
|
|
314
|
+
host["disk_read_mib"] = round(_delta(series.io_t, [v[0] for v in series.io], t0, t1) / (1 << 20), 1)
|
|
315
|
+
host["disk_write_mib"] = round(_delta(series.io_t, [v[1] for v in series.io], t0, t1) / (1 << 20), 1)
|
|
316
|
+
out["host"] = host
|
|
317
|
+
flows, pressure, system_cpu = _flows(series, t0, t1, devices, wall)
|
|
318
|
+
if series.nvlink_t:
|
|
319
|
+
tx = 0.0
|
|
320
|
+
for dev in devices:
|
|
321
|
+
usable = [(t, v[dev]) for t, v in zip(series.nvlink_t, series.nvlink) if v[dev] is not None]
|
|
322
|
+
if usable:
|
|
323
|
+
tx += _delta([u[0] for u in usable], [u[1][0] for u in usable], t0, t1)
|
|
324
|
+
# Every byte one GPU sends another receives, so transmit alone is the traffic.
|
|
325
|
+
flows["nvlink_mib"] = round(tx / 1024, 1)
|
|
326
|
+
if flows:
|
|
327
|
+
out["flows"] = flows
|
|
328
|
+
if pressure:
|
|
329
|
+
out["pressure"] = pressure
|
|
330
|
+
if system_cpu:
|
|
331
|
+
out["system_cpu"] = system_cpu
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _avg(rows, key):
|
|
336
|
+
values = [r[key] for r in rows if r.get(key) is not None]
|
|
337
|
+
return round(sum(values) / len(values), 1) if values else None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def thread_cpu_by_group(series, phases):
|
|
341
|
+
"""CPU seconds per thread per phase group, from the per-thread snapshots on slow ticks. Numbers in thread names are dropped
|
|
342
|
+
so the `cuda-EvtHandlr` threads of all GPUs add up; the busiest single thread is kept apart, because one saturated thread
|
|
343
|
+
is a bottleneck that an average over many threads hides."""
|
|
344
|
+
if not phases:
|
|
345
|
+
return {}
|
|
346
|
+
starts = [p["t"] for p in phases]
|
|
347
|
+
last = {}
|
|
348
|
+
by_name = {}
|
|
349
|
+
by_tid = {}
|
|
350
|
+
for tick in series.slow:
|
|
351
|
+
threads = tick["s"].get("threads")
|
|
352
|
+
if not threads:
|
|
353
|
+
continue
|
|
354
|
+
i = bisect.bisect_right(starts, tick["t"]) - 1
|
|
355
|
+
group = phases[i]["group"] if i >= 0 else "startup"
|
|
356
|
+
for tid, (comm, ticks) in threads.items():
|
|
357
|
+
delta = ticks - last.get(tid, 0)
|
|
358
|
+
last[tid] = ticks
|
|
359
|
+
if delta > 0:
|
|
360
|
+
name = re.sub(r"[0-9]+", "#", comm)
|
|
361
|
+
bucket = by_name.setdefault(group, {})
|
|
362
|
+
bucket[name] = bucket.get(name, 0.0) + delta / CLK_TCK
|
|
363
|
+
per_tid = by_tid.setdefault(group, {})
|
|
364
|
+
entry = per_tid.setdefault(tid, [comm, 0.0])
|
|
365
|
+
entry[1] += delta / CLK_TCK
|
|
366
|
+
out = {}
|
|
367
|
+
for group, names in by_name.items():
|
|
368
|
+
top = max(by_tid[group].values(), key=lambda entry: entry[1])
|
|
369
|
+
out[group] = {
|
|
370
|
+
"threads": {name: round(seconds, 2) for name, seconds in sorted(names.items(), key=lambda kv: -kv[1])[:8]},
|
|
371
|
+
"busiest": {"name": top[0], "cpu_s": round(top[1], 2)},
|
|
372
|
+
}
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _sum_flows(items):
|
|
377
|
+
"""Adds the moved bytes of many phases. Busy percentages become time-weighted, gauges keep their extreme."""
|
|
378
|
+
out = {}
|
|
379
|
+
wall_total = sum(wall for wall, _ in items) or 1.0
|
|
380
|
+
disks = {}
|
|
381
|
+
for wall, flows in items:
|
|
382
|
+
for name, disk in flows.get("disks", {}).items():
|
|
383
|
+
entry = disks.setdefault(name, {"read_mib": 0.0, "write_mib": 0.0, "busy_pct": 0.0})
|
|
384
|
+
entry["read_mib"] += disk["read_mib"]
|
|
385
|
+
entry["write_mib"] += disk["write_mib"]
|
|
386
|
+
entry["busy_pct"] += (disk["busy_pct"] or 0.0) * wall / wall_total
|
|
387
|
+
if disks:
|
|
388
|
+
out["disks"] = {name: {k: round(v, 1) for k, v in entry.items()} for name, entry in disks.items()}
|
|
389
|
+
for key in ("swap_in_mib", "swap_out_mib", "page_cache_refault_mib", "direct_reclaim_mib", "page_cache_change_mib",
|
|
390
|
+
"network_rx_mib", "network_tx_mib", "pcie_to_gpu_mib_est", "pcie_from_gpu_mib_est", "nvlink_mib"):
|
|
391
|
+
values = [flows[key] for _, flows in items if key in flows]
|
|
392
|
+
if values:
|
|
393
|
+
out[key] = round(sum(values), 1)
|
|
394
|
+
values = [flows["mem_available_min_mib"] for _, flows in items if "mem_available_min_mib" in flows]
|
|
395
|
+
if values:
|
|
396
|
+
out["mem_available_min_mib"] = min(values)
|
|
397
|
+
values = [flows["swap_used_max_mib"] for _, flows in items if "swap_used_max_mib" in flows]
|
|
398
|
+
if values:
|
|
399
|
+
out["swap_used_max_mib"] = max(values)
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def aggregate_group(metrics_list):
|
|
404
|
+
"""Sums and peaks over all phases of one group."""
|
|
405
|
+
out = {"phases": len(metrics_list), "wall_s": round(sum(m["wall_s"] for m in metrics_list), 6)}
|
|
406
|
+
tokens = [m["tokens"] for m in metrics_list if m.get("tokens")]
|
|
407
|
+
if tokens:
|
|
408
|
+
out["tokens"] = sum(tokens)
|
|
409
|
+
wall = out["wall_s"]
|
|
410
|
+
gpu = [m["gpu"] for m in metrics_list if m.get("gpu")]
|
|
411
|
+
if gpu:
|
|
412
|
+
energies = [g["energy_j"] for g in gpu]
|
|
413
|
+
energy = sum(energies) if all(e is not None for e in energies) else None
|
|
414
|
+
out["gpu"] = {
|
|
415
|
+
"mem_start_mib": gpu[0]["mem_start_mib"],
|
|
416
|
+
"mem_end_mib": gpu[-1]["mem_end_mib"],
|
|
417
|
+
"mem_peak_mib": max(g["mem_peak_mib"] for g in gpu),
|
|
418
|
+
"mem_peak_max_device_mib": max(g["mem_peak_max_device_mib"] for g in gpu),
|
|
419
|
+
"energy_j": round(energy, 3) if energy is not None else None,
|
|
420
|
+
"power_avg_w": round(energy / wall, 2) if energy is not None and wall > 0 else None,
|
|
421
|
+
"power_gauge_w": _weighted(metrics_list, "power_gauge_w"),
|
|
422
|
+
"sm_mhz_avg": _weighted(metrics_list, "sm_mhz_avg"),
|
|
423
|
+
"sm_mhz_min": min((g["sm_mhz_min"] for g in gpu if g["sm_mhz_min"] is not None), default=None),
|
|
424
|
+
"util_avg": _weighted(metrics_list, "util_avg"),
|
|
425
|
+
"util_min_device": _weighted(metrics_list, "util_min_device"),
|
|
426
|
+
"util_max_device": _weighted(metrics_list, "util_max_device"),
|
|
427
|
+
"util_mem_avg": _weighted(metrics_list, "util_mem_avg"),
|
|
428
|
+
"temp_c_max": max(g["temp_c_max"] for g in gpu),
|
|
429
|
+
"throttled": any(g["throttled"] for g in gpu),
|
|
430
|
+
"throttle": sorted({name for g in gpu for name in g["throttle"]}),
|
|
431
|
+
}
|
|
432
|
+
if energy is not None and out.get("tokens"):
|
|
433
|
+
out["gpu"]["j_per_token"] = round(energy / out["tokens"], 4)
|
|
434
|
+
host = [m["host"] for m in metrics_list if m.get("host")]
|
|
435
|
+
if host:
|
|
436
|
+
user = sum(h["user_s"] for h in host)
|
|
437
|
+
system = sum(h["sys_s"] for h in host)
|
|
438
|
+
out["host"] = {
|
|
439
|
+
"anon_start_mib": host[0]["anon_start_mib"],
|
|
440
|
+
"anon_end_mib": host[-1]["anon_end_mib"],
|
|
441
|
+
"anon_peak_mib": max(h["anon_peak_mib"] for h in host),
|
|
442
|
+
"file_start_mib": host[0]["file_start_mib"],
|
|
443
|
+
"file_end_mib": host[-1]["file_end_mib"],
|
|
444
|
+
"file_peak_mib": max(h["file_peak_mib"] for h in host),
|
|
445
|
+
"shmem_peak_mib": max(h["shmem_peak_mib"] for h in host),
|
|
446
|
+
"pinned_peak_mib": max(h["pinned_peak_mib"] for h in host),
|
|
447
|
+
"swap_peak_mib": max(h["swap_peak_mib"] for h in host),
|
|
448
|
+
"threads_max": max(h["threads_max"] for h in host),
|
|
449
|
+
"user_s": round(user, 3),
|
|
450
|
+
"sys_s": round(system, 3),
|
|
451
|
+
"busy_cores": round((user + system) / wall, 2) if wall > 0 else None,
|
|
452
|
+
"major_faults": sum(h["major_faults"] for h in host),
|
|
453
|
+
}
|
|
454
|
+
for key in ("disk_read_mib", "disk_write_mib"):
|
|
455
|
+
values = [h[key] for h in host if key in h]
|
|
456
|
+
if values:
|
|
457
|
+
out["host"][key] = round(sum(values), 1)
|
|
458
|
+
flows = [(m["wall_s"], m["flows"]) for m in metrics_list if m.get("flows")]
|
|
459
|
+
if flows:
|
|
460
|
+
out["flows"] = _sum_flows(flows)
|
|
461
|
+
pressures = [(m["wall_s"], m["pressure"]) for m in metrics_list if m.get("pressure")]
|
|
462
|
+
if pressures:
|
|
463
|
+
total = sum(w for w, _ in pressures) or 1.0
|
|
464
|
+
keys = {k for _, p in pressures for k in p}
|
|
465
|
+
out["pressure"] = {k: round(sum(p.get(k, 0.0) * w for w, p in pressures) / total, 1) for k in sorted(keys)}
|
|
466
|
+
system_cpu = [(m["wall_s"], m["system_cpu"]) for m in metrics_list if m.get("system_cpu")]
|
|
467
|
+
if system_cpu:
|
|
468
|
+
total = sum(w for w, _ in system_cpu) or 1.0
|
|
469
|
+
keys = {k for _, p in system_cpu for k in p}
|
|
470
|
+
out["system_cpu"] = {k: round(sum(p.get(k, 0.0) * w for w, p in system_cpu) / total, 2) for k in sorted(keys)}
|
|
471
|
+
return out
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _weighted(metrics_list, key):
|
|
475
|
+
total = weight = 0.0
|
|
476
|
+
for m in metrics_list:
|
|
477
|
+
gpu = m.get("gpu")
|
|
478
|
+
if gpu and gpu.get(key) is not None:
|
|
479
|
+
total += gpu[key] * m["wall_s"]
|
|
480
|
+
weight += m["wall_s"]
|
|
481
|
+
return round(total / weight, 1) if weight else None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
GROUP_ORDER = ["baseline", "startup", "run", "download", "load", "setup", "warmup", "prefill", "decode", "serve", "observe",
|
|
485
|
+
"teardown", "idle", "tail"]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def energy_self_check(groups):
|
|
489
|
+
"""Power from the energy counter against the mean of the power gauge, two independent readings, over the longest working phase. The
|
|
490
|
+
counter has been seen to under-report by half when read too often, and nothing else in a report would show that."""
|
|
491
|
+
candidates = [g for name, g in groups.items() if name in ("decode", "prefill", "serve", "run", "observe") and g.get("wall_s", 0) >= MIN_WALL_FOR_ENERGY_PER_TOKEN_S]
|
|
492
|
+
candidates = [g for g in candidates if (g.get("gpu") or {}).get("power_avg_w") and (g.get("gpu") or {}).get("power_gauge_w")]
|
|
493
|
+
if not candidates:
|
|
494
|
+
return None
|
|
495
|
+
gpu = max(candidates, key=lambda g: g["wall_s"])["gpu"]
|
|
496
|
+
return round(gpu["power_avg_w"] / gpu["power_gauge_w"], 3)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def throughput_from_phases(groups):
|
|
500
|
+
"""Tokens per second from marked phases, for engines that report nothing themselves. It includes whatever else happens inside
|
|
501
|
+
the phase, which is why an engine's own timers are preferred when there are any."""
|
|
502
|
+
out = {}
|
|
503
|
+
for name in ("prefill", "decode"):
|
|
504
|
+
group = groups.get(name) or {}
|
|
505
|
+
if group.get("tokens") and group.get("wall_s"):
|
|
506
|
+
out[name] = {"tokens_per_s": round(group["tokens"] / group["wall_s"], 3), "source": "phase time"}
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _attached_baseline(series, t_attached):
|
|
511
|
+
"""A process that was already running holds its memory before the first sample, so the GPU's memory then is not an idle level. The
|
|
512
|
+
idle level is what remains after taking away what the process itself holds, at the first reading that knows about it."""
|
|
513
|
+
for tick in series.slow:
|
|
514
|
+
if tick["t"] >= t_attached and any(tick["s"].get("self_mib", [])):
|
|
515
|
+
return [max(0, (tick["g"][dev][G_MEM] or 0) - tick["s"]["self_mib"][dev]) for dev in range(series.n_gpus)]
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def summarize(ticks, phases, t_end, attached=False):
|
|
520
|
+
"""phases: [{"name", "group", "t", ...}] sorted by t. Adds t_end and metrics in place, returns the report sections."""
|
|
521
|
+
series = Series(ticks)
|
|
522
|
+
for i, phase in enumerate(phases):
|
|
523
|
+
phase["t_end"] = phases[i + 1]["t"] if i + 1 < len(phases) else t_end
|
|
524
|
+
|
|
525
|
+
baseline = [p for p in phases if p["group"] == "baseline"]
|
|
526
|
+
baseline_mem = [0] * series.n_gpus
|
|
527
|
+
if baseline and ticks:
|
|
528
|
+
i, j = series.window(baseline[0]["t"], baseline[0]["t_end"])
|
|
529
|
+
for dev in range(series.n_gpus):
|
|
530
|
+
values = [tick["g"][dev][G_MEM] or 0 for tick in ticks[i:j]]
|
|
531
|
+
baseline_mem[dev] = round(sum(values) / len(values)) if values else 0
|
|
532
|
+
if attached:
|
|
533
|
+
baseline_mem = _attached_baseline(series, baseline[0]["t_end"]) or baseline_mem
|
|
534
|
+
devices = used_devices(series, baseline_mem)
|
|
535
|
+
|
|
536
|
+
groups = {}
|
|
537
|
+
for phase in phases:
|
|
538
|
+
phase["metrics"] = phase_metrics(series, phase["t"], phase["t_end"], devices, baseline_mem)
|
|
539
|
+
if phase.get("tokens") is not None:
|
|
540
|
+
phase["metrics"]["tokens"] = phase["tokens"]
|
|
541
|
+
groups.setdefault(phase["group"], []).append(phase["metrics"])
|
|
542
|
+
aggregated = {group: aggregate_group(metrics) for group, metrics in groups.items()}
|
|
543
|
+
for group, threads in thread_cpu_by_group(series, phases).items():
|
|
544
|
+
if group in aggregated:
|
|
545
|
+
host = aggregated[group].setdefault("host", {})
|
|
546
|
+
host["threads_cpu_s"] = threads["threads"]
|
|
547
|
+
host["busiest_thread"] = threads["busiest"]
|
|
548
|
+
wall = aggregated[group]["wall_s"]
|
|
549
|
+
if wall > 0:
|
|
550
|
+
host["busiest_thread"]["busy"] = round(min(1.0, threads["busiest"]["cpu_s"] / wall), 3)
|
|
551
|
+
ordered = {group: aggregated[group] for group in GROUP_ORDER if group in aggregated}
|
|
552
|
+
ordered.update({group: value for group, value in aggregated.items() if group not in ordered})
|
|
553
|
+
|
|
554
|
+
self_peak = [0] * series.n_gpus
|
|
555
|
+
for tick in series.slow:
|
|
556
|
+
for dev, mib in enumerate(tick["s"].get("self_mib", [])):
|
|
557
|
+
self_peak[dev] = max(self_peak[dev], mib)
|
|
558
|
+
gpu_devices = []
|
|
559
|
+
for dev in devices:
|
|
560
|
+
peak = max((tick["g"][dev][G_MEM] or 0) for tick in ticks)
|
|
561
|
+
gpu_devices.append({
|
|
562
|
+
"index": dev,
|
|
563
|
+
"baseline_mib": baseline_mem[dev],
|
|
564
|
+
"peak_mib": peak,
|
|
565
|
+
"peak_above_baseline_mib": peak - baseline_mem[dev],
|
|
566
|
+
"process_peak_mib": self_peak[dev],
|
|
567
|
+
})
|
|
568
|
+
tail = [p for p in phases if p["group"] == "tail"]
|
|
569
|
+
if tail and ticks:
|
|
570
|
+
last = ticks[-1]["g"]
|
|
571
|
+
for entry in gpu_devices:
|
|
572
|
+
entry["after_exit_mib"] = last[entry["index"]][G_MEM]
|
|
573
|
+
memory = {
|
|
574
|
+
"gpu": {
|
|
575
|
+
"devices": gpu_devices,
|
|
576
|
+
"peak_above_baseline_total_mib": sum(d["peak_above_baseline_mib"] for d in gpu_devices),
|
|
577
|
+
"peak_above_baseline_max_device_mib": max((d["peak_above_baseline_mib"] for d in gpu_devices), default=0),
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
if ticks:
|
|
581
|
+
memory["host"] = {
|
|
582
|
+
"anon_peak_mib": _kb_to_mib(max(t["h"][H_ANON] for t in ticks)),
|
|
583
|
+
"file_peak_mib": _kb_to_mib(max(t["h"][H_FILE] for t in ticks)),
|
|
584
|
+
"shmem_peak_mib": _kb_to_mib(max(t["h"][H_SHMEM] for t in ticks)),
|
|
585
|
+
"pinned_peak_mib": _kb_to_mib(max(t["h"][H_PIN] for t in ticks)),
|
|
586
|
+
"swap_peak_mib": _kb_to_mib(max(t["h"][H_SWAP] for t in ticks)),
|
|
587
|
+
"vm_hwm_sum_mib": _kb_to_mib(max(t["h"][H_HWM] for t in ticks)),
|
|
588
|
+
}
|
|
589
|
+
energy = {}
|
|
590
|
+
measured_groups = [g for g in ordered if g not in ("baseline", "tail")]
|
|
591
|
+
totals = [ordered[g].get("gpu", {}).get("energy_j") for g in measured_groups]
|
|
592
|
+
if totals and all(v is not None for v in totals):
|
|
593
|
+
energy["total_j"] = round(sum(totals), 3)
|
|
594
|
+
for group in ("prefill", "decode", "serve"):
|
|
595
|
+
j_per_token = ordered.get(group, {}).get("gpu", {}).get("j_per_token")
|
|
596
|
+
if j_per_token is not None and ordered[group]["wall_s"] >= MIN_WALL_FOR_ENERGY_PER_TOKEN_S:
|
|
597
|
+
energy[f"{group}_j_per_token"] = j_per_token
|
|
598
|
+
return {"groups": ordered, "memory": memory, "energy": energy, "devices_used": devices}
|