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/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"