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/diagnose.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Reads the numbers and says what they mean: what limits each phase, whether memory is being overused, and where data is
|
|
2
|
+
travelling between disk, RAM and GPU that it should not have to.
|
|
3
|
+
|
|
4
|
+
Every statement carries the numbers it rests on, and the report shows them. A rule only fires on evidence that survives short
|
|
5
|
+
runs and shared machines; when a phase is too short to judge it says so instead of guessing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import system
|
|
11
|
+
|
|
12
|
+
MIN_WALL_FOR_UTILIZATION_S = 1.0
|
|
13
|
+
ACTIVE_GROUPS = ("load", "setup", "warmup", "prefill", "decode", "serve", "run", "observe")
|
|
14
|
+
WORK_GROUPS = ("prefill", "decode", "serve", "run", "observe")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _rate(mib, wall):
|
|
18
|
+
return mib / wall if wall > 0 else 0.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _gib(mib):
|
|
22
|
+
return f"{mib / 1024:,.2f} GiB"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _disk_kinds(run):
|
|
26
|
+
return run.get("disks") or {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _busiest_disk(group, disks):
|
|
30
|
+
best = None
|
|
31
|
+
for name, disk in (group.get("flows", {}).get("disks") or {}).items():
|
|
32
|
+
if best is None or (disk.get("busy_pct") or 0) > (best[1].get("busy_pct") or 0):
|
|
33
|
+
best = (name, disk)
|
|
34
|
+
if not best:
|
|
35
|
+
return None
|
|
36
|
+
info = disks.get(best[0], {})
|
|
37
|
+
return {"name": best[0], "kind": info.get("kind", "disk"), **best[1]}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def really_throttled(gpu, run):
|
|
41
|
+
"""NVML sets the software power cap bit on GPUs far below their limit, so that bit alone needs the power to back it up. The
|
|
42
|
+
thermal and hardware reasons mean what they say."""
|
|
43
|
+
if not gpu.get("throttled"):
|
|
44
|
+
return False
|
|
45
|
+
reasons = set(gpu.get("throttle") or [])
|
|
46
|
+
if reasons & {"hw_slowdown", "sw_thermal", "hw_thermal", "hw_power_brake"}:
|
|
47
|
+
return True
|
|
48
|
+
if "sw_power_cap" not in reasons:
|
|
49
|
+
return False
|
|
50
|
+
used = run.get("devices_used") or []
|
|
51
|
+
limits = [d.get("power_limit_w") for d in run.get("devices") or [] if d.get("index") in used and d.get("power_limit_w")]
|
|
52
|
+
if not limits or not gpu.get("power_avg_w"):
|
|
53
|
+
return False
|
|
54
|
+
return gpu["power_avg_w"] / len(limits) >= 0.9 * (sum(limits) / len(limits))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def classify_phase(name, group, run):
|
|
58
|
+
"""What limited this phase. Returns {"limit", "confidence", "why": text, "evidence": {...}}."""
|
|
59
|
+
wall = group.get("wall_s", 0.0)
|
|
60
|
+
gpu = group.get("gpu") or {}
|
|
61
|
+
host = group.get("host") or {}
|
|
62
|
+
flows = group.get("flows") or {}
|
|
63
|
+
pressure = group.get("pressure") or {}
|
|
64
|
+
disks = _disk_kinds(run)
|
|
65
|
+
evidence = {}
|
|
66
|
+
|
|
67
|
+
util = gpu.get("util_avg")
|
|
68
|
+
util_max = gpu.get("util_max_device")
|
|
69
|
+
util_min = gpu.get("util_min_device")
|
|
70
|
+
if util is not None:
|
|
71
|
+
evidence["gpu_util_pct"] = util
|
|
72
|
+
busy_cores = host.get("busy_cores")
|
|
73
|
+
if busy_cores is not None:
|
|
74
|
+
evidence["cpu_cores"] = busy_cores
|
|
75
|
+
busiest = host.get("busiest_thread") or {}
|
|
76
|
+
thread_busy = busiest.get("busy")
|
|
77
|
+
if thread_busy is not None:
|
|
78
|
+
evidence["busiest_thread"] = f"{busiest.get('name')} {thread_busy * 100:.0f}%"
|
|
79
|
+
for key in ("io_some_pct", "memory_some_pct", "memory_full_pct", "cpu_some_pct"):
|
|
80
|
+
if key in pressure:
|
|
81
|
+
evidence[f"stall_{key[:-4]}_pct"] = pressure[key]
|
|
82
|
+
|
|
83
|
+
swap_rate = _rate(flows.get("swap_in_mib", 0.0) + flows.get("swap_out_mib", 0.0), wall)
|
|
84
|
+
memory_stall = pressure.get("memory_some_pct", 0.0)
|
|
85
|
+
refault_rate = _rate(flows.get("page_cache_refault_mib", 0.0), wall)
|
|
86
|
+
thrashing = refault_rate > 50.0 and memory_stall > 2.0
|
|
87
|
+
if swap_rate > 1.0 or pressure.get("memory_full_pct", 0.0) > 5.0 or thrashing:
|
|
88
|
+
causes = []
|
|
89
|
+
if swap_rate > 1.0:
|
|
90
|
+
causes.append(f"{swap_rate:,.0f} MiB/s of swap")
|
|
91
|
+
evidence["swap_mib_per_s"] = round(swap_rate, 1)
|
|
92
|
+
if refault_rate > 1.0:
|
|
93
|
+
causes.append(f"{refault_rate:,.0f} MiB/s of file pages dropped and read again")
|
|
94
|
+
evidence["refault_mib_per_s"] = round(refault_rate, 1)
|
|
95
|
+
causes.append(f"tasks stalled on memory {memory_stall:.0f}% of the time")
|
|
96
|
+
return {"limit": "ram", "confidence": "high", "evidence": evidence, "why": "Host RAM is short: " + ", ".join(causes) + "."}
|
|
97
|
+
|
|
98
|
+
disk = _busiest_disk(group, disks)
|
|
99
|
+
own_read = host.get("disk_read_mib")
|
|
100
|
+
read_mib = own_read if own_read is not None else (disk or {}).get("read_mib", 0.0)
|
|
101
|
+
io_stall = pressure.get("io_some_pct", 0.0)
|
|
102
|
+
if disk:
|
|
103
|
+
evidence["disk"] = f"{disk['name']} ({disk['kind']}) {disk.get('busy_pct', 0):.0f}% busy, {_rate(disk['read_mib'], wall):.0f} MiB/s read"
|
|
104
|
+
if read_mib and _rate(read_mib, wall) > 5.0 and (io_stall > 25.0 or (disk and (disk.get("busy_pct") or 0) > 60.0)):
|
|
105
|
+
kind = disk["kind"] if disk else "disk"
|
|
106
|
+
return {"limit": f"disk ({kind})", "confidence": "high", "evidence": evidence,
|
|
107
|
+
"why": f"Waiting on {kind} reads: {_rate(read_mib, wall):.0f} MiB/s, I/O stall {io_stall:.0f}% of the time."}
|
|
108
|
+
|
|
109
|
+
if wall < MIN_WALL_FOR_UTILIZATION_S:
|
|
110
|
+
return {"limit": "too short to judge", "confidence": "none", "evidence": evidence,
|
|
111
|
+
"why": f"{wall:.2f} s is too short for the GPU's utilization counter. Use a longer run."}
|
|
112
|
+
|
|
113
|
+
if util is not None and util > 50 and really_throttled(gpu, run):
|
|
114
|
+
reasons = ", ".join(gpu.get("throttle", []))
|
|
115
|
+
return {"limit": "gpu (throttled)", "confidence": "high", "evidence": evidence,
|
|
116
|
+
"why": f"GPU clocks were held down by {reasons} at {util:.0f}% utilization, {gpu.get('sm_mhz_avg')} MHz average."}
|
|
117
|
+
|
|
118
|
+
if util is not None and util_max is not None and util_min is not None and util_max - util_min > 25 and util_max >= 80:
|
|
119
|
+
return {"limit": "gpu imbalance", "confidence": "high", "evidence": evidence,
|
|
120
|
+
"why": f"Work is uneven across GPUs: {util_min:.0f}% to {util_max:.0f}%. The busiest one sets the pace."}
|
|
121
|
+
if util is not None and util >= 85:
|
|
122
|
+
return {"limit": "gpu", "confidence": "high", "evidence": evidence,
|
|
123
|
+
"why": f"GPU compute is the limit: {util:.0f}% busy. Use --nsys to see which kernels take the time."}
|
|
124
|
+
|
|
125
|
+
cpu_stall = pressure.get("cpu_some_pct", 0.0)
|
|
126
|
+
if cpu_stall > 30.0 and (run.get("host", {}).get("cpus") or 0) > 0:
|
|
127
|
+
return {"limit": "cpu contention", "confidence": "medium", "evidence": evidence,
|
|
128
|
+
"why": f"Runnable tasks waited for a CPU {cpu_stall:.0f}% of the time. Something else is competing for the cores."}
|
|
129
|
+
|
|
130
|
+
gpu_percent = util if util is not None else 0
|
|
131
|
+
if (util is None or util < 30) and busy_cores is not None and busy_cores >= 2.0:
|
|
132
|
+
return {"limit": "cpu (many threads)", "confidence": "high", "evidence": evidence,
|
|
133
|
+
"why": f"{busy_cores:.1f} CPU cores are busy while the GPU is {gpu_percent:.0f}% busy: the work is on the CPU."}
|
|
134
|
+
|
|
135
|
+
if (util is None or util < 30) and thread_busy is not None and thread_busy >= 0.7:
|
|
136
|
+
return {"limit": "cpu (one thread)", "confidence": "high", "evidence": evidence,
|
|
137
|
+
"why": f"Thread {busiest.get('name')} runs at {thread_busy * 100:.0f}% while the GPU is {gpu_percent:.0f}% busy. Everything waits on "
|
|
138
|
+
"this one thread; use --cpu-profile to see what it does."}
|
|
139
|
+
|
|
140
|
+
# The GPU does a real part of the work and also sits idle: the idle part goes to host work, or to the two waiting for each other.
|
|
141
|
+
if util is not None and util >= 30:
|
|
142
|
+
spinning = thread_busy is not None and thread_busy >= 0.7
|
|
143
|
+
detail = f"GPU {util:.0f}% busy with nothing else saturated, so the rest of the time is host work or synchronization."
|
|
144
|
+
if spinning:
|
|
145
|
+
detail = (f"GPU {util:.0f}% busy while thread {busiest.get('name')} runs at {thread_busy * 100:.0f}%: the host is working or spinning "
|
|
146
|
+
"on a wait during the GPU's idle part.")
|
|
147
|
+
return {"limit": "gpu + host wait", "confidence": "medium", "evidence": evidence,
|
|
148
|
+
"why": detail + " --nsys shows the gaps, --cpu-profile what the host does."}
|
|
149
|
+
|
|
150
|
+
if busy_cores is not None and busy_cores >= 0.7:
|
|
151
|
+
return {"limit": "cpu", "confidence": "low", "evidence": evidence,
|
|
152
|
+
"why": f"{busy_cores:.1f} CPU cores busy and the GPU {gpu_percent:.0f}% busy: host work, spread over threads that take turns."}
|
|
153
|
+
|
|
154
|
+
if name in ("serve", "observe"):
|
|
155
|
+
return {"limit": "client or idle", "confidence": "low", "evidence": evidence,
|
|
156
|
+
"why": "Nothing is saturated: the server has spare capacity at this load."}
|
|
157
|
+
return {"limit": "none visible", "confidence": "low", "evidence": evidence,
|
|
158
|
+
"why": "No GPU, CPU, disk or memory limit is visible. The wait is somewhere these counters do not see."}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def memory_findings(run):
|
|
162
|
+
"""Is memory being overused? Each finding says how much, where, and when."""
|
|
163
|
+
findings = []
|
|
164
|
+
memory = run.get("memory") or {}
|
|
165
|
+
groups = run.get("groups") or {}
|
|
166
|
+
devices = {d["index"]: d for d in run.get("devices") or []}
|
|
167
|
+
gpu_devices = (memory.get("gpu") or {}).get("devices") or []
|
|
168
|
+
|
|
169
|
+
def add(severity, what, text, **evidence):
|
|
170
|
+
findings.append({"severity": severity, "what": what, "text": text, "evidence": evidence})
|
|
171
|
+
|
|
172
|
+
for entry in gpu_devices:
|
|
173
|
+
total = devices.get(entry["index"], {}).get("memory_total_mib")
|
|
174
|
+
if total and entry["peak_mib"] >= 0.95 * total:
|
|
175
|
+
add("warning", "gpu_memory_nearly_full",
|
|
176
|
+
f"GPU {entry['index']} reached {entry['peak_mib']:,} of {total:,} MiB. There is no headroom left: a longer prompt or more "
|
|
177
|
+
"parallel requests can run out of memory.", peak_mib=entry["peak_mib"], total_mib=total)
|
|
178
|
+
peaks = [e["peak_above_baseline_mib"] for e in gpu_devices]
|
|
179
|
+
if len(peaks) >= 2 and min(peaks) > 0 and max(peaks) > 1.25 * min(peaks) and max(peaks) - min(peaks) > 256:
|
|
180
|
+
add("info", "gpu_memory_uneven",
|
|
181
|
+
f"GPUs are filled unevenly: {min(peaks):,} to {max(peaks):,} MiB. The fullest one decides how large a model or context fits.",
|
|
182
|
+
min_mib=min(peaks), max_mib=max(peaks))
|
|
183
|
+
|
|
184
|
+
steady_group = next((groups[g] for g in ("decode", "serve", "observe") if g in groups), None)
|
|
185
|
+
if steady_group and steady_group.get("gpu"):
|
|
186
|
+
peak_all = max((g.get("gpu", {}).get("mem_peak_mib", 0) for g in groups.values()), default=0)
|
|
187
|
+
steady = steady_group["gpu"].get("mem_end_mib", 0)
|
|
188
|
+
peak_group = max(groups, key=lambda g: groups[g].get("gpu", {}).get("mem_peak_mib", 0))
|
|
189
|
+
if peak_all - steady > max(256, 0.05 * max(steady, 1)):
|
|
190
|
+
add("info", "gpu_transient_peak",
|
|
191
|
+
f"GPU memory peaks {peak_all - steady:,} MiB above its steady level, during {peak_group}. That peak decides what fits, "
|
|
192
|
+
"not the steady level.", peak_mib=peak_all, steady_mib=steady, phase=peak_group)
|
|
193
|
+
|
|
194
|
+
for name in ("decode", "serve"):
|
|
195
|
+
group = groups.get(name) or {}
|
|
196
|
+
host = group.get("host") or {}
|
|
197
|
+
gpu = group.get("gpu") or {}
|
|
198
|
+
anon = host.get("anon_peak_mib", 0)
|
|
199
|
+
gpu_mem = gpu.get("mem_peak_mib", 0)
|
|
200
|
+
if anon > 2048 and gpu_mem > 0 and anon > 0.25 * gpu_mem:
|
|
201
|
+
add("info", "host_ram_held_while_on_gpu",
|
|
202
|
+
f"The process holds {_gib(anon)} of its own host RAM during {name}, next to {_gib(gpu_mem)} on the GPU. If that is a copy of "
|
|
203
|
+
"the weights it can usually be released after upload.", host_anon_mib=anon, gpu_mib=gpu_mem, phase=name)
|
|
204
|
+
file_mib = host.get("file_peak_mib", 0)
|
|
205
|
+
if file_mib > 4096 and gpu_mem > 0:
|
|
206
|
+
add("info", "weights_mapped_in_page_cache",
|
|
207
|
+
f"{_gib(file_mib)} of mapped files stay in RAM during {name}. These pages are reclaimable page cache, not a leak, but they "
|
|
208
|
+
"are why a later run loads fast and why the machine shows little free RAM.", file_mib=file_mib, phase=name)
|
|
209
|
+
start, end = host.get("anon_start_mib", 0), host.get("anon_end_mib", 0)
|
|
210
|
+
if end - start > max(128, 0.05 * max(start, 1)):
|
|
211
|
+
# One run cannot tell a leak from a cache filling up once, so only large growth is worth a warning.
|
|
212
|
+
add("warning" if end - start > max(1024, 0.2 * max(start, 1)) else "info", "host_memory_growing",
|
|
213
|
+
f"Host memory grew {end - start:,.0f} MiB during {name}, from {_gib(start)} to {_gib(end)}. If it keeps growing with more "
|
|
214
|
+
"requests it is a leak or an unbounded cache; if it levels off, a cache filling up.", start_mib=start, end_mib=end, phase=name)
|
|
215
|
+
start, end = gpu.get("mem_start_mib", 0), gpu.get("mem_end_mib", 0)
|
|
216
|
+
if end - start > max(256, 0.05 * max(start, 1)):
|
|
217
|
+
add("info", "gpu_memory_growing",
|
|
218
|
+
f"GPU memory grew {end - start:,} MiB during {name}. Buffers sized on demand can do this once; if it continues, look for growth "
|
|
219
|
+
"with context length.", start_mib=start, end_mib=end, phase=name)
|
|
220
|
+
|
|
221
|
+
host_memory = memory.get("host") or {}
|
|
222
|
+
if host_memory.get("swap_peak_mib", 0) > 64:
|
|
223
|
+
add("warning", "process_swapped",
|
|
224
|
+
f"{host_memory['swap_peak_mib']:,.0f} MiB of the process was in swap. Touching those pages later means disk reads in the middle of "
|
|
225
|
+
"inference.", swap_mib=host_memory["swap_peak_mib"])
|
|
226
|
+
lowest = None
|
|
227
|
+
total_ram = (run.get("machine_before") or {}).get("mem_total_mib")
|
|
228
|
+
for group in groups.values():
|
|
229
|
+
value = (group.get("flows") or {}).get("mem_available_min_mib")
|
|
230
|
+
if value is not None and (lowest is None or value < lowest):
|
|
231
|
+
lowest = value
|
|
232
|
+
if lowest is not None and total_ram and lowest < 0.05 * total_ram:
|
|
233
|
+
add("warning", "host_ram_nearly_full",
|
|
234
|
+
f"Available host RAM fell to {lowest:,} MiB of {total_ram:,}. The kernel starts dropping cache and swapping around here.",
|
|
235
|
+
available_mib=lowest, total_mib=total_ram)
|
|
236
|
+
|
|
237
|
+
for row in memory.get("breakdown_mib") or []:
|
|
238
|
+
if row.get("kind") == "host" and (row.get("model") or 0) > 1024 and gpu_devices:
|
|
239
|
+
add("info", "weights_on_host",
|
|
240
|
+
f"{_gib(row['model'])} of the model stays in host buffers, not on a GPU. CPU work reads it from there, so with mmap it depends on the "
|
|
241
|
+
"page cache, and the disk when the cache has been evicted.", host_model_mib=row["model"])
|
|
242
|
+
if row.get("kind") == "device" and row.get("self"):
|
|
243
|
+
# llama.cpp's table for its tensor-split device has the whole group's total but one GPU's share of the buffers. Taking that
|
|
244
|
+
# at face value would "find" most of the memory unaccounted for.
|
|
245
|
+
if str(row.get("name", "")).startswith("Meta"):
|
|
246
|
+
continue
|
|
247
|
+
unaccounted = row.get("unaccounted") or 0
|
|
248
|
+
if unaccounted > 0.15 * row["self"] and unaccounted > 256:
|
|
249
|
+
add("info", "gpu_memory_unaccounted",
|
|
250
|
+
f"{row.get('name')}: {unaccounted:,} MiB in use that the engine cannot account for, against {row['self']:,} MiB it can. "
|
|
251
|
+
"Other processes or driver overhead hold it.", unaccounted_mib=unaccounted, self_mib=row["self"])
|
|
252
|
+
compute = row.get("compute") or 0
|
|
253
|
+
if compute > 0.3 * row["self"] and compute > 256:
|
|
254
|
+
add("info", "compute_buffers_large",
|
|
255
|
+
f"{row.get('name')}: compute buffers take {compute:,} MiB of {row['self']:,} MiB. They scale with batch size, so a smaller "
|
|
256
|
+
"batch frees memory.", compute_mib=compute, self_mib=row["self"])
|
|
257
|
+
|
|
258
|
+
torch_stats = run.get("torch_memory") or {}
|
|
259
|
+
reserved, allocated = torch_stats.get("max_reserved_mib"), torch_stats.get("max_allocated_mib")
|
|
260
|
+
if reserved and allocated and reserved > 1.3 * allocated and reserved - allocated > 512:
|
|
261
|
+
add("info", "torch_reserved_above_allocated",
|
|
262
|
+
f"PyTorch reserved {reserved:,.0f} MiB but its tensors peaked at {allocated:,.0f} MiB. The difference sits in its allocator cache.",
|
|
263
|
+
reserved_mib=reserved, allocated_mib=allocated)
|
|
264
|
+
|
|
265
|
+
# The fullest pool decides. Engines publish several related gauges, and one finding about them is enough.
|
|
266
|
+
pools = [(name.split("{")[0], peak) for name, peak in ((run.get("workload") or {}).get("server_gauges_peak") or {}).items()
|
|
267
|
+
if peak is not None and any(word in name.lower().split("{")[0] for word in ("kv_cache_usage", "gpu_cache_usage", "token_usage"))]
|
|
268
|
+
if pools:
|
|
269
|
+
name, peak = max(pools, key=lambda item: item[1] / 100 if item[1] > 1.0 else item[1])
|
|
270
|
+
fraction = peak / 100 if peak > 1.0 else peak
|
|
271
|
+
if fraction < 0.25:
|
|
272
|
+
add("info", "kv_cache_mostly_unused",
|
|
273
|
+
f"The server's cache pool peaked at {fraction * 100:.0f}% ({name}). Most of the memory reserved for it was never used at this "
|
|
274
|
+
"load; a smaller reservation would fit, unless a heavier load is coming.", metric=name, peak=peak)
|
|
275
|
+
elif fraction > 0.95:
|
|
276
|
+
add("warning", "kv_cache_full",
|
|
277
|
+
f"The server's cache pool reached {fraction * 100:.0f}% ({name}). Requests are probably waiting or being preempted.",
|
|
278
|
+
metric=name, peak=peak)
|
|
279
|
+
|
|
280
|
+
# After attaching, the process is still running when the profile ends, so it still holding its memory says nothing.
|
|
281
|
+
tail_group = groups.get("tail") or {}
|
|
282
|
+
if tail_group and not run.get("attached"):
|
|
283
|
+
for entry in gpu_devices:
|
|
284
|
+
after = entry.get("after_exit_mib")
|
|
285
|
+
if after is not None and after - entry["baseline_mib"] > 128:
|
|
286
|
+
add("info", "gpu_memory_after_exit",
|
|
287
|
+
f"GPU {entry['index']} still holds {after - entry['baseline_mib']:,} MiB more than before the run, 1 s after it ended.",
|
|
288
|
+
after_mib=after, baseline_mib=entry["baseline_mib"])
|
|
289
|
+
return findings
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def movement_findings(run):
|
|
293
|
+
"""Data going back and forth between tiers."""
|
|
294
|
+
findings = []
|
|
295
|
+
groups = run.get("groups") or {}
|
|
296
|
+
before = run.get("machine_before") or {}
|
|
297
|
+
disks = _disk_kinds(run)
|
|
298
|
+
|
|
299
|
+
def add(severity, what, text, **evidence):
|
|
300
|
+
findings.append({"severity": severity, "what": what, "text": text, "evidence": evidence})
|
|
301
|
+
|
|
302
|
+
warmup = groups.get("warmup") or {}
|
|
303
|
+
if warmup.get("wall_s", 0.0) > 5.0:
|
|
304
|
+
grown = (warmup.get("gpu") or {}).get("mem_end_mib", 0) - (warmup.get("gpu") or {}).get("mem_start_mib", 0)
|
|
305
|
+
add("info", "slow_first_use",
|
|
306
|
+
f"The first use took {warmup['wall_s']:,.1f} s, and GPU memory grew {grown:,} MiB in it. Engines that load models on demand or compile "
|
|
307
|
+
"kernels on first use do this: the first user of a fresh server waits this long.", wall_s=warmup["wall_s"], gpu_growth_mib=grown)
|
|
308
|
+
|
|
309
|
+
load = groups.get("load") or {}
|
|
310
|
+
location = before.get("model_storage")
|
|
311
|
+
kind = system.storage_kind(location)
|
|
312
|
+
if kind in ("hdd", "network", "disk"):
|
|
313
|
+
# An array of rotating disks reports as rotational too and can be fast, so the speed actually measured decides how much this matters.
|
|
314
|
+
host_load = load.get("host") or {}
|
|
315
|
+
read_rate = _rate(host_load.get("disk_read_mib") or 0.0, load.get("wall_s", 0.0))
|
|
316
|
+
measured = (host_load.get("disk_read_mib") or 0.0) > 256
|
|
317
|
+
name = location.get("path")
|
|
318
|
+
if measured and read_rate >= 500:
|
|
319
|
+
add("info", "model_on_slow_storage", f"The model is on {kind} storage ({name}), which delivered {read_rate:,.0f} MiB/s here.", storage=kind, read_mib_per_s=round(read_rate))
|
|
320
|
+
elif measured:
|
|
321
|
+
add("warning", "model_on_slow_storage", f"The model is on {kind} storage ({name}) at only {read_rate:,.0f} MiB/s. Loading depends on it, and so does inference "
|
|
322
|
+
"if pages get evicted.", storage=kind, read_mib_per_s=round(read_rate))
|
|
323
|
+
else:
|
|
324
|
+
add("info", "model_on_slow_storage", f"The model is on {kind} storage ({name}). This run did not read it from disk; --cold-start measures what a first load costs.",
|
|
325
|
+
storage=kind)
|
|
326
|
+
|
|
327
|
+
if load:
|
|
328
|
+
host = load.get("host") or {}
|
|
329
|
+
read_mib = host.get("disk_read_mib")
|
|
330
|
+
wall = load.get("wall_s", 0.0)
|
|
331
|
+
cached = before.get("model_page_cache_fraction")
|
|
332
|
+
disk = _busiest_disk(load, disks)
|
|
333
|
+
if read_mib and read_mib > 256:
|
|
334
|
+
where = f" from {disk['name']} ({disk['kind']})" if disk else ""
|
|
335
|
+
add("info", "load_read_from_disk",
|
|
336
|
+
f"Load read {_gib(read_mib)}{where} at {_rate(read_mib, wall):,.0f} MiB/s" +
|
|
337
|
+
(f"; {cached * 100:.0f}% of the model was cached before the run." if cached is not None else "."),
|
|
338
|
+
read_mib=read_mib, cached_fraction=cached)
|
|
339
|
+
elif cached is not None and cached > 0.9:
|
|
340
|
+
add("info", "load_from_page_cache",
|
|
341
|
+
f"The model was {cached * 100:.0f}% in the page cache, so this load time is a warm one. A cold start reads it from disk and "
|
|
342
|
+
"takes longer.", cached_fraction=cached)
|
|
343
|
+
|
|
344
|
+
for name in ("prefill", "decode", "serve"):
|
|
345
|
+
group = groups.get(name) or {}
|
|
346
|
+
if not group:
|
|
347
|
+
continue
|
|
348
|
+
wall = group.get("wall_s", 0.0)
|
|
349
|
+
host = group.get("host") or {}
|
|
350
|
+
flows = group.get("flows") or {}
|
|
351
|
+
tokens = group.get("tokens") or 0
|
|
352
|
+
read_mib = host.get("disk_read_mib") or 0.0
|
|
353
|
+
faults = host.get("major_faults") or 0
|
|
354
|
+
if read_mib > 16 or faults > 256:
|
|
355
|
+
add("warning", "disk_reads_during_inference",
|
|
356
|
+
f"{_gib(read_mib)} read from disk and {faults:,} major page faults during {name}. The weights or cache are not staying in RAM: "
|
|
357
|
+
"evicted pages, mmap of tensors used on the CPU, or an engine offloading to disk.",
|
|
358
|
+
read_mib=read_mib, major_faults=faults, phase=name)
|
|
359
|
+
written = host.get("disk_write_mib") or 0.0
|
|
360
|
+
if written > 64:
|
|
361
|
+
add("info", "disk_writes_during_inference",
|
|
362
|
+
f"{_gib(written)} written to disk during {name}: a cache being offloaded to disk, or logging. Either way those writes share the "
|
|
363
|
+
"disk with any reads the engine needs.", write_mib=written, phase=name)
|
|
364
|
+
swap_in = flows.get("swap_in_mib", 0.0)
|
|
365
|
+
swap_out = flows.get("swap_out_mib", 0.0)
|
|
366
|
+
if swap_in + swap_out > 16:
|
|
367
|
+
add("warning", "swapping_during_inference",
|
|
368
|
+
f"The system swapped {swap_in:,.0f} MiB in and {swap_out:,.0f} MiB out during {name}.", swap_in_mib=swap_in, swap_out_mib=swap_out,
|
|
369
|
+
phase=name)
|
|
370
|
+
pcie_in = flows.get("pcie_to_gpu_mib_est")
|
|
371
|
+
pcie_out = flows.get("pcie_from_gpu_mib_est")
|
|
372
|
+
# A sample every second or so says nothing about a short phase, and an idle GPU's first reading can be far off. In a mixed serve
|
|
373
|
+
# phase the prompts dominate, so a per-token figure only means something for pure decode.
|
|
374
|
+
if name == "decode" and tokens and pcie_in is not None and pcie_out is not None and wall >= 3.0 and ((group.get("gpu") or {}).get("util_avg") or 0) >= 10:
|
|
375
|
+
per_token = (pcie_in + pcie_out) / tokens
|
|
376
|
+
if per_token > 8.0:
|
|
377
|
+
add("info", "heavy_host_gpu_traffic",
|
|
378
|
+
f"About {per_token:,.1f} MiB crosses PCIe per token during {name}: {pcie_in:,.0f} MiB toward the GPUs and {pcie_out:,.0f} MiB back. "
|
|
379
|
+
"That is tensors moving between RAM and GPU (partial offload, CPU-side layers), a collective going through host memory, or a GPU "
|
|
380
|
+
"polling host memory. --nsys gives the exact bytes of real copies, and whatever remains is the other two.",
|
|
381
|
+
mib_per_token=round(per_token, 2), phase=name)
|
|
382
|
+
nvlink = flows.get("nvlink_mib")
|
|
383
|
+
if tokens and nvlink and name != "serve":
|
|
384
|
+
add("info", "gpu_to_gpu_traffic", f"{nvlink / tokens:,.2f} MiB per token crosses NVLink during {name}.",
|
|
385
|
+
mib_per_token=round(nvlink / tokens, 3), phase=name)
|
|
386
|
+
gpu = group.get("gpu") or {}
|
|
387
|
+
util = gpu.get("util_avg")
|
|
388
|
+
cores = host.get("busy_cores") or 0
|
|
389
|
+
if util is not None and wall >= MIN_WALL_FOR_UTILIZATION_S and util < 50 and cores >= 3.0:
|
|
390
|
+
add("info", "cpu_gpu_split",
|
|
391
|
+
f"During {name} the GPU is {util:.0f}% busy while {cores:.1f} CPU cores work. Part of the model is probably running on the CPU.",
|
|
392
|
+
gpu_util=util, cpu_cores=cores, phase=name)
|
|
393
|
+
return findings
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def timeline_findings(run):
|
|
397
|
+
"""From a traced run: how each GPU's time divides, and what the host does meanwhile."""
|
|
398
|
+
findings = []
|
|
399
|
+
nsys = run.get("nsys") or {}
|
|
400
|
+
for name in ("prefill", "decode", "serve", "run"):
|
|
401
|
+
group = (nsys.get("groups") or {}).get(name)
|
|
402
|
+
if not group or not group.get("devices"):
|
|
403
|
+
continue
|
|
404
|
+
spans = [d["span_ms"] for d in group["devices"] if d["span_ms"] > 0]
|
|
405
|
+
if not spans:
|
|
406
|
+
continue
|
|
407
|
+
n = len(group["devices"])
|
|
408
|
+
span = sum(spans) / n
|
|
409
|
+
kernel = sum(d["kernel_ms"] for d in group["devices"]) / n
|
|
410
|
+
comm = sum(d["comm_ms"] for d in group["devices"]) / n
|
|
411
|
+
copy = sum(d["copy_ms"] for d in group["devices"]) / n
|
|
412
|
+
idle = sum(d["idle_ms"] for d in group["devices"]) / n
|
|
413
|
+
top = group.get("top_kernels") or []
|
|
414
|
+
top_text = ", ".join(f"{k['name']} {k['share'] * 100:.0f}%" for k in top[:3])
|
|
415
|
+
text = (f"{name}: each GPU spends {kernel / span * 100:.0f}% of its time in kernels, {comm / span * 100:.0f}% in collectives, "
|
|
416
|
+
f"{copy / span * 100:.0f}% in copies and {idle / span * 100:.0f}% idle (tracing slows the host, so idle reads high). Top kernels: {top_text}.")
|
|
417
|
+
findings.append({"severity": "info", "what": "gpu_time_split", "text": text,
|
|
418
|
+
"evidence": {"phase": name, "kernel_ms": round(kernel, 2), "comm_ms": round(comm, 2), "copy_ms": round(copy, 2),
|
|
419
|
+
"idle_ms": round(idle, 2)}})
|
|
420
|
+
host_api = group.get("host_api") or {}
|
|
421
|
+
if host_api.get("top"):
|
|
422
|
+
top_api = ", ".join(f"{a['name']} {a['ms']:,.0f} ms" for a in host_api["top"][:3])
|
|
423
|
+
findings.append({"severity": "info", "what": "host_api_time", "text": f"{name}: the host spent its CUDA calls in {top_api}.",
|
|
424
|
+
"evidence": {"phase": name}})
|
|
425
|
+
copies = group.get("copies") or {}
|
|
426
|
+
moved = ", ".join(f"{kind} {c['mib']:,.1f} MiB" + (f" ({c['mib_per_token']:.3f} per token)" if c.get("mib_per_token") else "")
|
|
427
|
+
for kind, c in copies.items())
|
|
428
|
+
if moved:
|
|
429
|
+
findings.append({"severity": "info", "what": "exact_copies", "text": f"{name}: copies traced: {moved}.", "evidence": {"phase": name}})
|
|
430
|
+
return findings
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
_DRIVER_MODULES = ("libcuda", "libcudart", "libnvidia", "[vdso]", "libnccl")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def cpu_findings(run):
|
|
437
|
+
"""From a CPU-sampled run: how much host time is inside the GPU driver, and which of the engine's own functions take the rest. The
|
|
438
|
+
self time is what a speed-up can act on; the callers around the main loop are in every sample and say nothing."""
|
|
439
|
+
findings = []
|
|
440
|
+
profile = run.get("cpu_profile") or {}
|
|
441
|
+
for name in ("prefill", "decode", "serve", "run", "load"):
|
|
442
|
+
group = (profile.get("groups") or {}).get(name)
|
|
443
|
+
if not group or group.get("samples", 0) < 50:
|
|
444
|
+
continue
|
|
445
|
+
in_driver = sum(m["share"] for m in group["modules"] if m["name"].startswith(_DRIVER_MODULES))
|
|
446
|
+
own = [x for x in group["self"] if not x["name"].endswith("!?") and x["share"] >= 0.03 and not x["name"].startswith(("[", "__", "ioctl", "cu"))]
|
|
447
|
+
top = ", ".join(_describe_function(x) for x in own[:4])
|
|
448
|
+
text = f"{name}: {in_driver * 100:.0f}% of host CPU samples are inside the GPU driver (submitting, or spinning on a wait)."
|
|
449
|
+
if top:
|
|
450
|
+
text += f" Hottest functions outside it: {top}."
|
|
451
|
+
findings.append({"severity": "info", "what": "host_cpu_split", "text": text, "evidence": {"phase": name, "driver_share": round(in_driver, 3)}})
|
|
452
|
+
return findings
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _describe_function(row):
|
|
456
|
+
"""A function's share, how many threads ran it, and how much of the phase it holds up: the part a speed-up could win back."""
|
|
457
|
+
threads = row.get("threads", 1)
|
|
458
|
+
where = "on 1 thread" if threads == 1 else f"on {threads} threads"
|
|
459
|
+
text = f"{row['name'][:48]} {row['share'] * 100:.0f}% {where}"
|
|
460
|
+
held = row.get("pct_of_wall_on_busiest_thread")
|
|
461
|
+
if held is not None:
|
|
462
|
+
text += f" (at most {held:.0f}% of the phase)"
|
|
463
|
+
return text
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def serving_findings(run):
|
|
467
|
+
"""How a server behaves as load rises: does more concurrency buy more throughput, and what does waiting cost?"""
|
|
468
|
+
findings = []
|
|
469
|
+
levels = sorted(((t["concurrency"], name, t) for name, t in (run.get("throughput") or {}).items()
|
|
470
|
+
if isinstance(t, dict) and "concurrency" in t and t.get("output_tokens_per_s")), key=lambda item: item[0])
|
|
471
|
+
for (c0, name0, t0), (c1, name1, t1) in zip(levels, levels[1:]):
|
|
472
|
+
gain = t1["output_tokens_per_s"] / t0["output_tokens_per_s"]
|
|
473
|
+
ttft0 = (t0.get("ttft_ms") or {}).get("p50")
|
|
474
|
+
ttft1 = (t1.get("ttft_ms") or {}).get("p50")
|
|
475
|
+
evidence = {"from": c0, "to": c1, "throughput_gain": round(gain, 2)}
|
|
476
|
+
latency = ""
|
|
477
|
+
if ttft0 and ttft1:
|
|
478
|
+
latency = f", and the median first token moved from {ttft0:,.0f} to {ttft1:,.0f} ms"
|
|
479
|
+
evidence.update(ttft_from_ms=ttft0, ttft_to_ms=ttft1)
|
|
480
|
+
if gain < 1 + 0.2 * (c1 / c0 - 1):
|
|
481
|
+
findings.append({"severity": "warning", "what": "no_parallel_scaling",
|
|
482
|
+
"text": f"Going from {c0} to {c1} concurrent requests raised throughput only {gain:.2f} times{latency}. Requests are queueing: "
|
|
483
|
+
"the server is handling them about one at a time, so check its parallelism setting.", "evidence": evidence})
|
|
484
|
+
else:
|
|
485
|
+
findings.append({"severity": "info", "what": "parallel_scaling",
|
|
486
|
+
"text": f"Going from {c0} to {c1} concurrent requests raised throughput {gain:.2f} times{latency}.", "evidence": evidence})
|
|
487
|
+
failed = [(name, t) for _, name, t in levels if t.get("failed")]
|
|
488
|
+
for name, t in failed:
|
|
489
|
+
findings.append({"severity": "warning", "what": "failed_requests",
|
|
490
|
+
"text": f"{t['failed']} of {t['requests']} requests failed in {name}: {t.get('first_error', '')[:160]}", "evidence": {"phase": name}})
|
|
491
|
+
return findings
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def next_steps(run, bottlenecks):
|
|
495
|
+
"""Which deeper tool answers the question this run raised."""
|
|
496
|
+
steps = []
|
|
497
|
+
limits = {b["limit"] for b in bottlenecks.values()}
|
|
498
|
+
if not run.get("perturbed"):
|
|
499
|
+
if limits & {"gpu", "gpu + host wait", "gpu imbalance"}:
|
|
500
|
+
steps.append("--nsys: which kernels take the GPU's time, and what it waits on in the gaps")
|
|
501
|
+
if limits & {"cpu (one thread)", "cpu (many threads)", "gpu + host wait"}:
|
|
502
|
+
steps.append("--cpu-profile: which functions the host threads spend their time in")
|
|
503
|
+
return steps
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def diagnose(run):
|
|
507
|
+
groups = run.get("groups") or {}
|
|
508
|
+
bottlenecks = {}
|
|
509
|
+
for name in ACTIVE_GROUPS:
|
|
510
|
+
if name in groups:
|
|
511
|
+
bottlenecks[name] = classify_phase(name, groups[name], run)
|
|
512
|
+
# A server can hit a different limit at each load level, so each level gets its own verdict.
|
|
513
|
+
levels = [p for p in run.get("phases") or [] if p.get("group") == "serve" and p.get("metrics")]
|
|
514
|
+
if len(levels) > 1:
|
|
515
|
+
del bottlenecks["serve"]
|
|
516
|
+
for level in levels:
|
|
517
|
+
bottlenecks[level["name"]] = classify_phase("serve", level["metrics"], run)
|
|
518
|
+
return {
|
|
519
|
+
"bottlenecks": bottlenecks,
|
|
520
|
+
"memory": memory_findings(run),
|
|
521
|
+
"movement": movement_findings(run),
|
|
522
|
+
"serving": serving_findings(run),
|
|
523
|
+
"timeline": timeline_findings(run) + cpu_findings(run),
|
|
524
|
+
"next_steps": next_steps(run, bottlenecks),
|
|
525
|
+
}
|