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/api.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Using llmperfscope from Python, for engines that are libraries (transformers, vLLM's LLM class, ...).
|
|
2
|
+
|
|
3
|
+
The recommended form keeps the sampler in another process, so it cannot disturb the engine:
|
|
4
|
+
|
|
5
|
+
# script.py, started with: llmperfscope run -- python script.py
|
|
6
|
+
import llmperfscope
|
|
7
|
+
with llmperfscope.phase("load"):
|
|
8
|
+
model = AutoModelForCausalLM.from_pretrained(...)
|
|
9
|
+
timer = llmperfscope.TokenTimer()
|
|
10
|
+
with llmperfscope.phase("decode"):
|
|
11
|
+
model.generate(**inputs, max_new_tokens=128, streamer=timer)
|
|
12
|
+
llmperfscope.mark_tokens(timer)
|
|
13
|
+
|
|
14
|
+
`Profile` does the same from inside one process, without the command line.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import __version__, analyze, diagnose, machine, markers
|
|
28
|
+
from .gpu import open_backend
|
|
29
|
+
from .sampler import Sampler, load_ticks
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _torch_memory():
|
|
33
|
+
"""PyTorch's own view of GPU memory, when PyTorch is already loaded: what its tensors use against what its allocator holds.
|
|
34
|
+
The difference cannot be seen from outside the process. Importing it here would be wrong: this must not load anything."""
|
|
35
|
+
torch = sys.modules.get("torch")
|
|
36
|
+
if torch is None:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
if not torch.cuda.is_available():
|
|
40
|
+
return None
|
|
41
|
+
mib = 1 << 20
|
|
42
|
+
return {
|
|
43
|
+
"allocated_mib": round(sum(torch.cuda.memory_allocated(i) for i in range(torch.cuda.device_count())) / mib, 1),
|
|
44
|
+
"reserved_mib": round(sum(torch.cuda.memory_reserved(i) for i in range(torch.cuda.device_count())) / mib, 1),
|
|
45
|
+
"max_allocated_mib": round(sum(torch.cuda.max_memory_allocated(i) for i in range(torch.cuda.device_count())) / mib, 1),
|
|
46
|
+
"max_reserved_mib": round(sum(torch.cuda.max_memory_reserved(i) for i in range(torch.cuda.device_count())) / mib, 1),
|
|
47
|
+
}
|
|
48
|
+
except Exception: # noqa: BLE001 - a stats call must never break the program being measured
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _attach_torch(meta):
|
|
53
|
+
stats = _torch_memory()
|
|
54
|
+
if stats:
|
|
55
|
+
meta["torch"] = stats
|
|
56
|
+
return meta
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@contextmanager
|
|
60
|
+
def phase(name, group=None, tokens=None, next_phase="idle", **meta):
|
|
61
|
+
"""Everything inside is phase `name`. On leaving, the time goes to `next_phase` so a gap is not counted as this phase."""
|
|
62
|
+
markers.mark(name, group=group, tokens=tokens, **_attach_torch(dict(meta)))
|
|
63
|
+
try:
|
|
64
|
+
yield
|
|
65
|
+
finally:
|
|
66
|
+
markers.mark(next_phase, group=next_phase, **_attach_torch({}))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TokenTimer:
|
|
70
|
+
"""Pass as `streamer=` to `model.generate` in transformers. Records when each token arrives, with no dependency on transformers:
|
|
71
|
+
it only has the `put` and `end` methods a streamer needs. Note that using a streamer has a small cost of its own."""
|
|
72
|
+
|
|
73
|
+
def __init__(self):
|
|
74
|
+
self.times = []
|
|
75
|
+
self.batches = 0
|
|
76
|
+
|
|
77
|
+
def put(self, value):
|
|
78
|
+
self.batches += 1
|
|
79
|
+
# The first call hands over the prompt, the later ones one new token each.
|
|
80
|
+
if self.batches > 1:
|
|
81
|
+
self.times.append(time.monotonic())
|
|
82
|
+
|
|
83
|
+
def end(self):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def tokens(self):
|
|
88
|
+
return len(self.times)
|
|
89
|
+
|
|
90
|
+
def summary(self):
|
|
91
|
+
if not self.times:
|
|
92
|
+
return {"tokens": 0}
|
|
93
|
+
gaps = [b - a for a, b in zip(self.times, self.times[1:])]
|
|
94
|
+
ordered = sorted(gaps)
|
|
95
|
+
out = {"tokens": len(self.times)}
|
|
96
|
+
if gaps:
|
|
97
|
+
out.update({"tokens_per_s": round(len(gaps) / sum(gaps), 3),
|
|
98
|
+
"itl_ms_p50": round(ordered[len(ordered) // 2] * 1000, 3),
|
|
99
|
+
"itl_ms_p99": round(ordered[min(len(ordered) - 1, int(len(ordered) * 0.99))] * 1000, 3)})
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mark_tokens(timer, group="decode"):
|
|
104
|
+
"""Attaches a TokenTimer's result to the phase that just ran."""
|
|
105
|
+
markers.mark("token_stats", group="idle", decode_group=group, token_stats=timer.summary())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Profile:
|
|
109
|
+
"""Samples from a thread of this process. Fine for finding bottlenecks; for speed numbers prefer `llmperfscope run`."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, out, label="", hz=50.0, slow_hz=2.0, baseline_s=0.5):
|
|
112
|
+
self.dir = Path(out)
|
|
113
|
+
self.label = label
|
|
114
|
+
self.hz = hz
|
|
115
|
+
self.slow_hz = slow_hz
|
|
116
|
+
self.baseline_s = baseline_s
|
|
117
|
+
self.events = []
|
|
118
|
+
self.lock = threading.Lock()
|
|
119
|
+
self.report = None
|
|
120
|
+
|
|
121
|
+
def _on_mark(self, event):
|
|
122
|
+
phase_name = event["phase"]
|
|
123
|
+
record = {"name": phase_name, "group": event.get("group", phase_name), "t": event["t"]}
|
|
124
|
+
if event.get("tokens") is not None:
|
|
125
|
+
record["tokens"] = event["tokens"]
|
|
126
|
+
if event.get("meta"):
|
|
127
|
+
record["meta"] = event["meta"]
|
|
128
|
+
with self.lock:
|
|
129
|
+
self.events.append(record)
|
|
130
|
+
|
|
131
|
+
def __enter__(self):
|
|
132
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
self.backend = open_backend()
|
|
134
|
+
self.before = machine.snapshot(self.backend, cpu_seconds=0.0, ignore={os.getpid()})
|
|
135
|
+
self.sampler = Sampler(self.dir / "samples.jsonl.gz", self.backend, hz=self.hz, slow_hz=self.slow_hz)
|
|
136
|
+
self.sampler.set_roots({os.getpid()})
|
|
137
|
+
self.t0 = time.monotonic_ns()
|
|
138
|
+
self.events.append({"name": "baseline", "group": "baseline", "t": self.t0})
|
|
139
|
+
self.sampler.start()
|
|
140
|
+
time.sleep(self.baseline_s)
|
|
141
|
+
self.events.append({"name": "run", "group": "run", "t": time.monotonic_ns()})
|
|
142
|
+
markers.add_listener(self._on_mark)
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def mark(self, phase_name, group=None, tokens=None, **meta):
|
|
146
|
+
markers.mark(phase_name, group=group, tokens=tokens, **meta)
|
|
147
|
+
|
|
148
|
+
def __exit__(self, exc_type, exc, tb):
|
|
149
|
+
markers.remove_listener(self._on_mark)
|
|
150
|
+
self.events.append({"name": "tail", "group": "tail", "t": time.monotonic_ns()})
|
|
151
|
+
time.sleep(0.2)
|
|
152
|
+
self.sampler.stop()
|
|
153
|
+
self.sampler.join(timeout=30)
|
|
154
|
+
t_end = time.monotonic_ns()
|
|
155
|
+
after = machine.snapshot(self.backend, cpu_seconds=0.0, ignore={os.getpid()})
|
|
156
|
+
devices = self.backend.devices
|
|
157
|
+
self.backend.close()
|
|
158
|
+
ticks = load_ticks(self.dir / "samples.jsonl.gz")
|
|
159
|
+
phases = sorted(self.events, key=lambda p: p["t"])
|
|
160
|
+
sections = analyze.summarize(ticks, phases, t_end) if ticks else {}
|
|
161
|
+
torch_stats = _collect_torch(phases)
|
|
162
|
+
for item in phases:
|
|
163
|
+
item["start_s"] = round((item.pop("t") - self.t0) / 1e9, 6)
|
|
164
|
+
item["end_s"] = round((item.pop("t_end", t_end) - self.t0) / 1e9, 6)
|
|
165
|
+
self.report = {
|
|
166
|
+
"schema": "llmperfscope.run/1",
|
|
167
|
+
"tool": {"name": "llmperfscope", "version": __version__},
|
|
168
|
+
"label": self.label,
|
|
169
|
+
"status": "failed" if exc_type else "ok",
|
|
170
|
+
"perturbed": False,
|
|
171
|
+
"adapter": "in-process",
|
|
172
|
+
"host": machine.host_info(),
|
|
173
|
+
"devices": devices,
|
|
174
|
+
"disks": self.sampler.system.disks,
|
|
175
|
+
"machine_before": self.before,
|
|
176
|
+
"machine_after": after,
|
|
177
|
+
"sampler": {"hz_target": self.hz, "max_gap_ms": round(self.sampler.max_gap_ns / 1e6, 1),
|
|
178
|
+
"cpu_seconds": round(self.sampler.cpu_seconds, 3), "error": self.sampler.error},
|
|
179
|
+
"notes": ["sampled from inside the profiled process"],
|
|
180
|
+
"phases": phases,
|
|
181
|
+
}
|
|
182
|
+
if torch_stats:
|
|
183
|
+
self.report["torch_memory"] = torch_stats
|
|
184
|
+
self.report.update(sections)
|
|
185
|
+
derived = analyze.throughput_from_phases(sections.get("groups") or {})
|
|
186
|
+
if derived:
|
|
187
|
+
self.report["throughput"] = derived
|
|
188
|
+
self.report["diagnosis"] = diagnose.diagnose(self.report)
|
|
189
|
+
with open(self.dir / "run.json", "w") as f:
|
|
190
|
+
json.dump(self.report, f, indent=1)
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _collect_torch(phases):
|
|
195
|
+
"""The highest PyTorch memory figures reported by any mark, for the memory findings."""
|
|
196
|
+
out = {}
|
|
197
|
+
for item in phases:
|
|
198
|
+
stats = (item.get("meta") or {}).get("torch")
|
|
199
|
+
if stats:
|
|
200
|
+
for key, value in stats.items():
|
|
201
|
+
out[key] = max(out.get(key, 0.0), value)
|
|
202
|
+
return out
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""`llmperfscope check`: what can be measured on this machine, what cannot, and what it would take."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from . import cpuprof, machine, nsys
|
|
10
|
+
from .gpu import open_backend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read(path):
|
|
14
|
+
try:
|
|
15
|
+
with open(path) as f:
|
|
16
|
+
return f.read().strip()
|
|
17
|
+
except OSError:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _driver_param(name):
|
|
22
|
+
text = _read("/proc/driver/nvidia/params") or ""
|
|
23
|
+
for line in text.splitlines():
|
|
24
|
+
if line.startswith(name + ":"):
|
|
25
|
+
return line.split(":", 1)[1].strip()
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def collect():
|
|
30
|
+
items = []
|
|
31
|
+
|
|
32
|
+
def add(what, status, detail=""):
|
|
33
|
+
items.append({"what": what, "status": status, "detail": detail})
|
|
34
|
+
|
|
35
|
+
add("process memory, CPU, disk reads (/proc)", "yes" if os.path.exists("/proc/self/status") else "no",
|
|
36
|
+
"" if os.path.exists("/proc/self/status") else "needs Linux")
|
|
37
|
+
backend = open_backend()
|
|
38
|
+
if backend.name == "none":
|
|
39
|
+
add("GPU memory, power, energy, clocks, throttling", "no", backend.reason)
|
|
40
|
+
else:
|
|
41
|
+
rows = backend.fast()
|
|
42
|
+
slow = backend.slow()
|
|
43
|
+
names = sorted({d["name"] for d in backend.devices})
|
|
44
|
+
add("GPU memory, power, clocks, utilization", "yes", f"{len(rows)} x {', '.join(names)}")
|
|
45
|
+
add("GPU energy counter", "yes" if backend.devices and backend.energy_mj(0) is not None else "no")
|
|
46
|
+
add("per-process GPU memory", "yes" if slow else "no")
|
|
47
|
+
nvlink = [d["index"] for d in backend.devices if d.get("nvlink")]
|
|
48
|
+
add("NVLink traffic", "yes" if nvlink else "no", f"GPUs {nvlink}" if nvlink else "no NVLink or not reported")
|
|
49
|
+
backend.close()
|
|
50
|
+
add("per-thread CPU time", "yes")
|
|
51
|
+
|
|
52
|
+
tool = nsys.find_nsys()
|
|
53
|
+
add("GPU timeline: kernels, copies, host API (nsys)", "yes" if tool else "no",
|
|
54
|
+
tool or "install Nsight Systems or set CUDA_HOME")
|
|
55
|
+
admin_only = _driver_param("RmProfilingAdminOnly")
|
|
56
|
+
if admin_only == "0":
|
|
57
|
+
add("GPU hardware counters (nsys --gpu-metrics-devices, ncu)", "yes")
|
|
58
|
+
elif admin_only is None:
|
|
59
|
+
add("GPU hardware counters (nsys --gpu-metrics-devices, ncu)", "unknown", "no NVIDIA driver parameters found")
|
|
60
|
+
else:
|
|
61
|
+
add("GPU hardware counters (nsys --gpu-metrics-devices, ncu)", "needs admin",
|
|
62
|
+
"RmProfilingAdminOnly=1: load the driver with NVreg_RestrictProfilingToAdminUsers=0 or run as root")
|
|
63
|
+
|
|
64
|
+
paranoid = _read("/proc/sys/kernel/perf_event_paranoid")
|
|
65
|
+
if paranoid is not None and int(paranoid) <= 2:
|
|
66
|
+
add("perf record", "yes", f"perf_event_paranoid={paranoid}")
|
|
67
|
+
else:
|
|
68
|
+
add("perf record", "needs admin", f"perf_event_paranoid={paranoid}: `sysctl kernel.perf_event_paranoid=2`")
|
|
69
|
+
try:
|
|
70
|
+
library = cpuprof.library_path()
|
|
71
|
+
add("host CPU hot spots without perf (--cpu-profile)", "yes", str(library))
|
|
72
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
73
|
+
add("host CPU hot spots without perf (--cpu-profile)", "no", str(e))
|
|
74
|
+
add("model page cache state", "yes")
|
|
75
|
+
add("file tools", "yes" if shutil.which("nm") else "partly", "" if shutil.which("nm") else "nm missing: no CPU symbols")
|
|
76
|
+
return {"host": machine.host_info(), "items": items}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def render(info):
|
|
80
|
+
lines = [f"{info['host'].get('hostname')}: {info['host'].get('cpus')} CPUs", "", "| measurement | available | |", "|---|---|---|"]
|
|
81
|
+
for item in info["items"]:
|
|
82
|
+
lines.append(f"| {item['what']} | {item['status']} | {item['detail']} |")
|
|
83
|
+
return "\n".join(lines) + "\n"
|