llmperfscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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}