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/compare.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""A/B comparison. `diff` compares finished runs, `matrix` runs both sides itself in alternating order across many cases.
|
|
2
|
+
|
|
3
|
+
Speed, memory and energy are judged together, so a change cannot look like a win while it costs memory. A difference only
|
|
4
|
+
counts when the arms do not overlap and it is larger than the noise floor, because a single ratio from a few runs is mostly
|
|
5
|
+
noise: the same binary against itself has shown ratios up to 1.006 at 8 GPUs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import runner
|
|
15
|
+
|
|
16
|
+
# name -> (getter, direction): +1 means bigger is better, -1 smaller is better.
|
|
17
|
+
METRICS = {
|
|
18
|
+
"prefill_tps": (lambda r: _get(r, "throughput", "prefill", "tokens_per_s"), +1),
|
|
19
|
+
"decode_tps": (lambda r: _get(r, "throughput", "decode", "tokens_per_s"), +1),
|
|
20
|
+
"gpu_peak_mib": (lambda r: _get(r, "memory", "gpu", "peak_above_baseline_total_mib"), -1),
|
|
21
|
+
"gpu_peak_max_device_mib": (lambda r: _get(r, "memory", "gpu", "peak_above_baseline_max_device_mib"), -1),
|
|
22
|
+
"host_anon_peak_mib": (lambda r: _get(r, "memory", "host", "anon_peak_mib"), -1),
|
|
23
|
+
"load_s": (lambda r: _get(r, "groups", "load", "wall_s"), -1),
|
|
24
|
+
"prefill_j_per_token": (lambda r: _get(r, "energy", "prefill_j_per_token"), -1),
|
|
25
|
+
"decode_j_per_token": (lambda r: _get(r, "energy", "decode_j_per_token"), -1),
|
|
26
|
+
}
|
|
27
|
+
# Smallest difference that can mean anything, per metric: relative, then absolute. Memory rounds to whole MiB and allocators
|
|
28
|
+
# differ by a little from run to run; load time depends on the page cache; energy has its own counter resolution.
|
|
29
|
+
FLOORS = {
|
|
30
|
+
"prefill_tps": (0.003, 0.0),
|
|
31
|
+
"decode_tps": (0.003, 0.0),
|
|
32
|
+
"gpu_peak_mib": (0.002, 16.0),
|
|
33
|
+
"gpu_peak_max_device_mib": (0.002, 16.0),
|
|
34
|
+
"host_anon_peak_mib": (0.02, 64.0),
|
|
35
|
+
"load_s": (0.05, 0.5),
|
|
36
|
+
"prefill_j_per_token": (0.05, 0.0),
|
|
37
|
+
"decode_j_per_token": (0.03, 0.0),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get(run, *keys):
|
|
42
|
+
node = run
|
|
43
|
+
for key in keys:
|
|
44
|
+
if not isinstance(node, dict) or key not in node:
|
|
45
|
+
return None
|
|
46
|
+
node = node[key]
|
|
47
|
+
return node
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def judge(metric, a_values, b_values, noise=0.0):
|
|
51
|
+
"""Verdict for B against A: better, worse or same. `noise` is a relative floor measured with a null comparison."""
|
|
52
|
+
a_values = [v for v in a_values if v is not None]
|
|
53
|
+
b_values = [v for v in b_values if v is not None]
|
|
54
|
+
if not a_values or not b_values:
|
|
55
|
+
return {"verdict": "missing"}
|
|
56
|
+
a_mean = sum(a_values) / len(a_values)
|
|
57
|
+
b_mean = sum(b_values) / len(b_values)
|
|
58
|
+
direction = METRICS[metric][1]
|
|
59
|
+
ratio = b_mean / a_mean if a_mean else None
|
|
60
|
+
relative_floor, absolute_floor = FLOORS[metric]
|
|
61
|
+
floor = max(relative_floor, noise) * abs(a_mean)
|
|
62
|
+
floor = max(floor, absolute_floor)
|
|
63
|
+
difference = (b_mean - a_mean) * direction # positive: B is better
|
|
64
|
+
separated = min(b_values) > max(a_values) or max(b_values) < min(a_values)
|
|
65
|
+
verdict = "same"
|
|
66
|
+
if abs(difference) > floor and separated:
|
|
67
|
+
verdict = "better" if difference > 0 else "worse"
|
|
68
|
+
return {"a": round(a_mean, 4), "b": round(b_mean, 4), "ratio": round(ratio, 4) if ratio is not None else None,
|
|
69
|
+
"verdict": verdict, "a_runs": a_values, "b_runs": b_values}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def diff(runs_a, runs_b, noise=None):
|
|
73
|
+
"""Runs of side A against runs of side B. Failed runs and traced runs do not count; they are listed instead."""
|
|
74
|
+
usable_a = [r for r in runs_a if r.get("status") == "ok" and not r.get("perturbed")]
|
|
75
|
+
usable_b = [r for r in runs_b if r.get("status") == "ok" and not r.get("perturbed")]
|
|
76
|
+
out = {"metrics": {}, "dropped_runs": (len(runs_a) - len(usable_a)) + (len(runs_b) - len(usable_b))}
|
|
77
|
+
dirty = sum(1 for r in usable_a + usable_b if not (r.get("machine_before") or {"clean": True}).get("clean", True))
|
|
78
|
+
if dirty:
|
|
79
|
+
out["dirty_runs"] = dirty
|
|
80
|
+
for metric, (getter, _direction) in METRICS.items():
|
|
81
|
+
result = judge(metric, [getter(r) for r in usable_a], [getter(r) for r in usable_b], (noise or {}).get(metric, 0.0))
|
|
82
|
+
if result["verdict"] != "missing":
|
|
83
|
+
out["metrics"][metric] = result
|
|
84
|
+
worse = [m for m, v in out["metrics"].items() if v["verdict"] == "worse"]
|
|
85
|
+
better = [m for m, v in out["metrics"].items() if v["verdict"] == "better"]
|
|
86
|
+
out["worse"], out["better"] = worse, better
|
|
87
|
+
if worse and better:
|
|
88
|
+
out["verdict"] = "tradeoff"
|
|
89
|
+
else:
|
|
90
|
+
out["verdict"] = "regression" if worse else "improvement" if better else "neutral"
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_run(path):
|
|
95
|
+
path = Path(path)
|
|
96
|
+
if path.is_dir():
|
|
97
|
+
path = path / "run.json"
|
|
98
|
+
with open(path) as f:
|
|
99
|
+
return json.load(f)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_diff(result, a_label="A", b_label="B"):
|
|
103
|
+
lines = [f"| metric | {a_label} | {b_label} | ratio | |", "|---|---:|---:|---:|---|"]
|
|
104
|
+
for metric, v in result["metrics"].items():
|
|
105
|
+
mark = {"better": "better", "worse": "**worse**", "same": ""}[v["verdict"]]
|
|
106
|
+
lines.append(f"| {metric} | {v['a']:,} | {v['b']:,} | {v['ratio']} | {mark} |")
|
|
107
|
+
lines.append(f"\nVerdict: **{result['verdict']}**")
|
|
108
|
+
if result["dropped_runs"]:
|
|
109
|
+
lines.append(f"{result['dropped_runs']} failed or traced runs were left out.")
|
|
110
|
+
if result.get("dirty_runs"):
|
|
111
|
+
lines.append(f"{result['dirty_runs']} runs started on a machine that was not clean.")
|
|
112
|
+
return "\n".join(lines) + "\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_cases(path):
|
|
116
|
+
with open(path) as f:
|
|
117
|
+
spec = json.load(f)
|
|
118
|
+
defaults = spec.get("defaults", {})
|
|
119
|
+
cases = []
|
|
120
|
+
for case in spec["cases"]:
|
|
121
|
+
merged = {
|
|
122
|
+
"label": case["label"],
|
|
123
|
+
"binary": case.get("binary", defaults.get("binary", "llama-bench")),
|
|
124
|
+
"prefix": case.get("prefix", defaults.get("prefix", [])),
|
|
125
|
+
"args": list(defaults.get("args", [])) + list(case.get("args", [])),
|
|
126
|
+
"env": {**defaults.get("env", {}), **case.get("env", {})},
|
|
127
|
+
"model": case.get("model"),
|
|
128
|
+
"gpus": case.get("gpus"),
|
|
129
|
+
}
|
|
130
|
+
cases.append(merged)
|
|
131
|
+
return cases
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _command(case, bin_dir):
|
|
135
|
+
command = list(case["prefix"]) + [str(Path(bin_dir) / case["binary"])]
|
|
136
|
+
if case["model"]:
|
|
137
|
+
command += ["-m", case["model"]]
|
|
138
|
+
return command + case["args"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _tag(label):
|
|
142
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", label)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_matrix(cases, a, b, out_dir, order="ABBA", timeout_s=1800.0, a_env=None, b_env=None, only=None, noise=None, log=print,
|
|
146
|
+
precheck="wait"):
|
|
147
|
+
"""a and b are (name, bin_dir). Fresh process per arm, alternating order, machine re-checked before every arm."""
|
|
148
|
+
out_dir = Path(out_dir)
|
|
149
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
table = {"a": a[0], "b": b[0], "order": order, "cases": []}
|
|
151
|
+
for case in cases:
|
|
152
|
+
if only and not re.search(only, case["label"]):
|
|
153
|
+
continue
|
|
154
|
+
if case["model"] and not Path(case["model"]).exists():
|
|
155
|
+
log(f"{case['label']}: model missing, skipped")
|
|
156
|
+
table["cases"].append({"label": case["label"], "skipped": "model missing"})
|
|
157
|
+
continue
|
|
158
|
+
runs = {"A": [], "B": []}
|
|
159
|
+
for i, side in enumerate(order):
|
|
160
|
+
name, bin_dir = a if side == "A" else b
|
|
161
|
+
env = dict(case["env"])
|
|
162
|
+
env.update((a_env if side == "A" else b_env) or {})
|
|
163
|
+
if case["gpus"] is not None:
|
|
164
|
+
env["CUDA_VISIBLE_DEVICES"] = case["gpus"]
|
|
165
|
+
options = runner.Options(out=str(out_dir / _tag(case["label"]) / f"{i}_{side}_{name}"), label=f"{case['label']} [{name}]",
|
|
166
|
+
env=env, timeout_s=timeout_s, quiet=True, precheck=precheck)
|
|
167
|
+
log(f"{case['label']}: arm {i + 1}/{len(order)} ({name})")
|
|
168
|
+
try:
|
|
169
|
+
report = runner.Run(_command(case, bin_dir), options).execute()
|
|
170
|
+
except runner.Refused as e:
|
|
171
|
+
log(f" refused: {e}")
|
|
172
|
+
continue
|
|
173
|
+
if report["status"] != "ok":
|
|
174
|
+
log(f" arm ended with status {report['status']}")
|
|
175
|
+
runs[side].append(report)
|
|
176
|
+
result = diff(runs["A"], runs["B"], (noise or {}).get(case["label"]))
|
|
177
|
+
result["label"] = case["label"]
|
|
178
|
+
table["cases"].append(result)
|
|
179
|
+
with open(out_dir / "matrix.json", "w") as f:
|
|
180
|
+
json.dump(table, f, indent=1)
|
|
181
|
+
write_table(table, out_dir / "table.md")
|
|
182
|
+
log(f" -> {result['verdict']}: " + ", ".join(f"{m} {v['ratio']}" for m, v in result["metrics"].items()
|
|
183
|
+
if m in ("prefill_tps", "decode_tps")))
|
|
184
|
+
return table
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_table(table, path):
|
|
188
|
+
columns = [("prefill_tps", "pp"), ("decode_tps", "tg"), ("gpu_peak_mib", "GPU mem"), ("host_anon_peak_mib", "host anon"),
|
|
189
|
+
("load_s", "load"), ("decode_j_per_token", "tg J/tok")]
|
|
190
|
+
header = "| case | " + " | ".join(f"{title} {table['b']}/{table['a']}" for _, title in columns) + " | verdict |"
|
|
191
|
+
lines = [header, "|---|" + "---:|" * len(columns) + "---|"]
|
|
192
|
+
for case in table["cases"]:
|
|
193
|
+
if "skipped" in case:
|
|
194
|
+
lines.append(f"| {case['label']} | {case['skipped']} |")
|
|
195
|
+
continue
|
|
196
|
+
cells = []
|
|
197
|
+
for metric, _ in columns:
|
|
198
|
+
value = case["metrics"].get(metric)
|
|
199
|
+
if not value:
|
|
200
|
+
cells.append("-")
|
|
201
|
+
continue
|
|
202
|
+
cell = f"{value['ratio']:.3f}" if value["ratio"] is not None else "-"
|
|
203
|
+
if value["verdict"] == "worse":
|
|
204
|
+
cell = f"**{cell}** worse"
|
|
205
|
+
elif value["verdict"] == "better":
|
|
206
|
+
cell = f"{cell} better"
|
|
207
|
+
cells.append(cell)
|
|
208
|
+
extra = f" ({case['dropped_runs']} arms failed)" if case["dropped_runs"] else ""
|
|
209
|
+
if case.get("worse"):
|
|
210
|
+
extra += ": costs " + ", ".join(case["worse"])
|
|
211
|
+
lines.append(f"| {case['label']} | " + " | ".join(cells) + f" | {case['verdict']}{extra} |")
|
|
212
|
+
with open(path, "w") as f:
|
|
213
|
+
f.write("\n".join(lines) + "\n")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def noise_from_null(matrix_path):
|
|
217
|
+
"""Noise floors from a null comparison (same binary on both sides): per case and metric, the spread that appeared with
|
|
218
|
+
nothing changed. Pass the file to later matrices with --noise."""
|
|
219
|
+
with open(matrix_path) as f:
|
|
220
|
+
table = json.load(f)
|
|
221
|
+
out = {}
|
|
222
|
+
for case in table["cases"]:
|
|
223
|
+
if "metrics" not in case:
|
|
224
|
+
continue
|
|
225
|
+
floors = {}
|
|
226
|
+
for metric, value in case["metrics"].items():
|
|
227
|
+
values = value["a_runs"] + value["b_runs"]
|
|
228
|
+
mean = sum(values) / len(values)
|
|
229
|
+
if mean:
|
|
230
|
+
floors[metric] = round((max(values) - min(values)) / abs(mean), 5)
|
|
231
|
+
out[case["label"]] = floors
|
|
232
|
+
return out
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`docker run ...` as a target. The docker client is only a remote control: the engine is a child of the daemon, so the client's
|
|
2
|
+
process tree holds nothing worth measuring. llmperfscope asks docker for the container's first process and follows that instead.
|
|
3
|
+
Memory, CPU and per-process GPU memory all stay visible from the host, because a container's processes are host processes."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DockerTarget:
|
|
15
|
+
def __init__(self, tool, run_dir):
|
|
16
|
+
self.tool = tool
|
|
17
|
+
self.run_dir = Path(run_dir).resolve()
|
|
18
|
+
self.cidfile = self.run_dir / "container.id"
|
|
19
|
+
self.container = None
|
|
20
|
+
self.pid = None
|
|
21
|
+
self.image = None
|
|
22
|
+
self._done = threading.Event()
|
|
23
|
+
|
|
24
|
+
def prepare(self, cmd, index, marker_file):
|
|
25
|
+
"""Adds what llmperfscope needs right after `docker run`: the id file, and the marker file for scripts inside the container."""
|
|
26
|
+
if self.cidfile.exists():
|
|
27
|
+
self.cidfile.unlink()
|
|
28
|
+
extra = ["--cidfile", str(self.cidfile), "-e", "LLMPERFSCOPE_MARKERS=/llmperfscope-run/markers.log", "-v", f"{self.run_dir}:/llmperfscope-run"]
|
|
29
|
+
return cmd[:index + 2] + extra + cmd[index + 2:]
|
|
30
|
+
|
|
31
|
+
def follow(self, on_pid, timeout_s=300.0):
|
|
32
|
+
"""Finds the container's first process as soon as docker has one, in the background."""
|
|
33
|
+
|
|
34
|
+
def wait():
|
|
35
|
+
deadline = time.monotonic() + timeout_s
|
|
36
|
+
while time.monotonic() < deadline and not self._done.is_set():
|
|
37
|
+
if self.cidfile.exists():
|
|
38
|
+
self.container = self.cidfile.read_text().strip() or None
|
|
39
|
+
if self.container:
|
|
40
|
+
done = subprocess.run([self.tool, "inspect", "-f", "{{.State.Pid}}", self.container], capture_output=True, text=True,
|
|
41
|
+
check=False)
|
|
42
|
+
value = done.stdout.strip()
|
|
43
|
+
if value.isdigit() and int(value) > 0:
|
|
44
|
+
self.pid = int(value)
|
|
45
|
+
on_pid(self.pid)
|
|
46
|
+
image = subprocess.run([self.tool, "inspect", "-f", "{{.Config.Image}} {{.Image}}", self.container],
|
|
47
|
+
capture_output=True, text=True, check=False)
|
|
48
|
+
self.image = image.stdout.strip()
|
|
49
|
+
return
|
|
50
|
+
time.sleep(0.2)
|
|
51
|
+
|
|
52
|
+
threading.Thread(target=wait, daemon=True, name="llmperfscope-container").start()
|
|
53
|
+
|
|
54
|
+
def identity(self):
|
|
55
|
+
"""Which image ran, as the report's record of which code this was."""
|
|
56
|
+
if not self.image:
|
|
57
|
+
return {}
|
|
58
|
+
name, _, digest = self.image.partition(" ")
|
|
59
|
+
return {"container_image": name, "container_image_id": digest}
|
|
60
|
+
|
|
61
|
+
def stop(self, wait_s=30):
|
|
62
|
+
"""Stops the container itself. A signal to the client only reaches it while the client is attached."""
|
|
63
|
+
self._done.set()
|
|
64
|
+
if self.container:
|
|
65
|
+
subprocess.run([self.tool, "stop", "-t", str(int(wait_s)), self.container], capture_output=True, text=True, check=False)
|
|
66
|
+
|
|
67
|
+
def remove_if_running(self):
|
|
68
|
+
self._done.set()
|
|
69
|
+
if not self.container:
|
|
70
|
+
return False
|
|
71
|
+
done = subprocess.run([self.tool, "inspect", "-f", "{{.State.Running}}", self.container], capture_output=True, text=True, check=False)
|
|
72
|
+
if done.stdout.strip() == "true":
|
|
73
|
+
subprocess.run([self.tool, "kill", self.container], capture_output=True, text=True, check=False)
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tool_name(cmd, index):
|
|
79
|
+
return os.path.basename(cmd[index])
|
llmperfscope/cpuprof.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Host CPU hot spots without perf: builds and loads native/cpuprof.c, then symbolizes its samples per phase group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import glob
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import struct
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections import Counter, defaultdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
NATIVE = Path(__file__).parent / "native"
|
|
16
|
+
MAGIC = 0x4c5053434f504531
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def library_path():
|
|
20
|
+
"""Builds the shim next to the package, or in the user's cache when the package directory is read-only."""
|
|
21
|
+
source = NATIVE / "cpuprof.c"
|
|
22
|
+
compiler = None
|
|
23
|
+
for directory in (NATIVE, Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "llmperfscope"):
|
|
24
|
+
target = directory / "libllmperfscope_cpu.so"
|
|
25
|
+
if target.exists() and target.stat().st_mtime >= source.stat().st_mtime:
|
|
26
|
+
return target
|
|
27
|
+
compiler = compiler or shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
|
28
|
+
if not compiler:
|
|
29
|
+
raise FileNotFoundError("the CPU profiler needs a C compiler to build its shim once")
|
|
30
|
+
try:
|
|
31
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with tempfile.NamedTemporaryFile(dir=directory, suffix=".so", delete=False) as tmp:
|
|
33
|
+
temporary = tmp.name
|
|
34
|
+
except OSError:
|
|
35
|
+
continue
|
|
36
|
+
subprocess.run([compiler, "-O2", "-g", "-shared", "-fPIC", "-pthread", "-o", temporary, str(source), "-ldl", "-lrt"], check=True)
|
|
37
|
+
os.replace(temporary, target)
|
|
38
|
+
return target
|
|
39
|
+
raise OSError("no writable directory for the CPU profiler shim")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def prepare_env(env, run_dir, hz):
|
|
43
|
+
env = dict(env)
|
|
44
|
+
library = str(library_path())
|
|
45
|
+
existing = env.get("LD_PRELOAD")
|
|
46
|
+
env["LD_PRELOAD"] = f"{library}:{existing}" if existing else library
|
|
47
|
+
env["LLMPERFSCOPE_CPUPROF_OUT"] = str(Path(run_dir).resolve() / "cpuprof")
|
|
48
|
+
env["LLMPERFSCOPE_CPUPROF_HZ"] = str(hz)
|
|
49
|
+
return env
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Symbols:
|
|
53
|
+
"""Address -> function for one mapped file, from its ELF symbol tables."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, path):
|
|
56
|
+
self.path = path
|
|
57
|
+
self.starts = []
|
|
58
|
+
self.ends = []
|
|
59
|
+
self.names = []
|
|
60
|
+
self.segments = []
|
|
61
|
+
if os.path.exists(path):
|
|
62
|
+
self.segments = _load_segments(path)
|
|
63
|
+
pairs = {}
|
|
64
|
+
nm = shutil.which("nm")
|
|
65
|
+
if nm:
|
|
66
|
+
for extra in ([], ["-D"]):
|
|
67
|
+
done = subprocess.run([nm, "-C", "-S", "--defined-only", *extra, path], capture_output=True, text=True,
|
|
68
|
+
check=False, errors="replace")
|
|
69
|
+
for line in done.stdout.splitlines():
|
|
70
|
+
parts = line.split(None, 3)
|
|
71
|
+
if len(parts) == 4 and parts[2] in "tTwWiI":
|
|
72
|
+
try:
|
|
73
|
+
pairs.setdefault(int(parts[0], 16), (int(parts[1], 16), parts[3]))
|
|
74
|
+
except ValueError:
|
|
75
|
+
pass
|
|
76
|
+
for address in sorted(pairs):
|
|
77
|
+
self.starts.append(address)
|
|
78
|
+
self.ends.append(address + pairs[address][0])
|
|
79
|
+
self.names.append(pairs[address][1])
|
|
80
|
+
|
|
81
|
+
def lookup(self, file_offset):
|
|
82
|
+
"""Only an address inside a symbol's extent gets its name. In a stripped library such as the GPU driver, the nearest
|
|
83
|
+
exported symbol before an address says nothing about the code there, and printing it would mislead."""
|
|
84
|
+
address = file_offset
|
|
85
|
+
for p_offset, p_vaddr, p_filesz in self.segments:
|
|
86
|
+
if p_offset <= file_offset < p_offset + p_filesz:
|
|
87
|
+
address = file_offset - p_offset + p_vaddr
|
|
88
|
+
break
|
|
89
|
+
i = bisect.bisect_right(self.starts, address) - 1
|
|
90
|
+
if i >= 0 and address < self.ends[i]:
|
|
91
|
+
return self.names[i]
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_segments(path):
|
|
96
|
+
"""PT_LOAD program headers of a 64-bit ELF: maps file offsets to the addresses nm prints."""
|
|
97
|
+
try:
|
|
98
|
+
with open(path, "rb") as f:
|
|
99
|
+
header = f.read(64)
|
|
100
|
+
if header[:4] != b"\x7fELF" or header[4] != 2:
|
|
101
|
+
return []
|
|
102
|
+
e_phoff = struct.unpack_from("<Q", header, 32)[0]
|
|
103
|
+
e_phentsize, e_phnum = struct.unpack_from("<HH", header, 54)
|
|
104
|
+
f.seek(e_phoff)
|
|
105
|
+
table = f.read(e_phentsize * e_phnum)
|
|
106
|
+
except OSError:
|
|
107
|
+
return []
|
|
108
|
+
segments = []
|
|
109
|
+
for i in range(e_phnum):
|
|
110
|
+
base = i * e_phentsize
|
|
111
|
+
p_type = struct.unpack_from("<I", table, base)[0]
|
|
112
|
+
if p_type == 1:
|
|
113
|
+
p_offset, p_vaddr = struct.unpack_from("<QQ", table, base + 8)
|
|
114
|
+
p_filesz = struct.unpack_from("<Q", table, base + 32)[0]
|
|
115
|
+
segments.append((p_offset, p_vaddr, p_filesz))
|
|
116
|
+
return segments
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_maps(path):
|
|
120
|
+
ranges = []
|
|
121
|
+
with open(path) as f:
|
|
122
|
+
for line in f:
|
|
123
|
+
parts = line.split(None, 5)
|
|
124
|
+
if len(parts) == 6 and "x" in parts[1]:
|
|
125
|
+
start, end = (int(x, 16) for x in parts[0].split("-"))
|
|
126
|
+
ranges.append((start, end, int(parts[2], 16), parts[5].strip()))
|
|
127
|
+
ranges.sort()
|
|
128
|
+
return ranges
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_samples(path):
|
|
132
|
+
"""[(time ns, tid, thread name, frames)], dropped count, samples per CPU second."""
|
|
133
|
+
with open(path, "rb") as f:
|
|
134
|
+
magic, count, dropped, max_frames, hz = struct.unpack("<5Q", f.read(40))
|
|
135
|
+
if magic != MAGIC:
|
|
136
|
+
raise ValueError("not a llmperfscope CPU profile")
|
|
137
|
+
size = 32 + 8 * max_frames
|
|
138
|
+
layout = f"<QII16s{max_frames}Q"
|
|
139
|
+
samples = []
|
|
140
|
+
for _ in range(count):
|
|
141
|
+
raw = f.read(size)
|
|
142
|
+
if len(raw) < size:
|
|
143
|
+
break
|
|
144
|
+
values = struct.unpack(layout, raw)
|
|
145
|
+
samples.append((values[0], values[1], values[3].split(b"\0", 1)[0].decode("utf-8", "replace"), values[4:4 + values[2]]))
|
|
146
|
+
return samples, dropped, hz
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def summarize(run_dir, phases, t_baseline_ns, top=20):
|
|
150
|
+
"""Per phase group: where the CPU samples are, by module, function and thread. For the hot functions it also says how many threads
|
|
151
|
+
ran them, which is what tells serial code from parallel code."""
|
|
152
|
+
files = sorted(glob.glob(str(Path(run_dir) / "cpuprof.*.bin")))
|
|
153
|
+
if not files:
|
|
154
|
+
return {"error": "no samples: the process was killed before it could write them, or ran no CPU"}
|
|
155
|
+
starts = [t_baseline_ns + int(p["start_s"] * 1e9) for p in phases]
|
|
156
|
+
groups = [p["group"] for p in phases]
|
|
157
|
+
wall_by_group = Counter()
|
|
158
|
+
for phase in phases:
|
|
159
|
+
wall_by_group[phase["group"]] += phase["end_s"] - phase["start_s"]
|
|
160
|
+
symbols = {}
|
|
161
|
+
per_group = defaultdict(lambda: {"n": 0, "modules": Counter(), "self": Counter(), "inclusive": Counter(), "threads": Counter(),
|
|
162
|
+
"per_tid": Counter(), "self_tids": defaultdict(Counter), "tid_names": {}})
|
|
163
|
+
total = dropped_total = 0
|
|
164
|
+
period_s = 0.005
|
|
165
|
+
for path in files:
|
|
166
|
+
ranges = _load_maps(path[:-4] + ".maps")
|
|
167
|
+
range_starts = [r[0] for r in ranges]
|
|
168
|
+
samples, dropped, hz = _read_samples(path)
|
|
169
|
+
dropped_total += dropped
|
|
170
|
+
period_s = 1.0 / hz if hz else period_s
|
|
171
|
+
|
|
172
|
+
def resolve(address):
|
|
173
|
+
i = bisect.bisect_right(range_starts, address) - 1
|
|
174
|
+
if i < 0 or address >= ranges[i][1]:
|
|
175
|
+
return "[unknown]", "[unknown]"
|
|
176
|
+
start, _end, offset, module_path = ranges[i]
|
|
177
|
+
module = os.path.basename(module_path) or "[anon]"
|
|
178
|
+
if not module_path.startswith("/"):
|
|
179
|
+
return module, module
|
|
180
|
+
table = symbols.get(module_path)
|
|
181
|
+
if table is None:
|
|
182
|
+
table = symbols[module_path] = _Symbols(module_path)
|
|
183
|
+
name = table.lookup(address - start + offset)
|
|
184
|
+
return module, name if name else f"{module}!?"
|
|
185
|
+
|
|
186
|
+
cache = {}
|
|
187
|
+
for t, tid, comm, frames in samples:
|
|
188
|
+
if not frames:
|
|
189
|
+
continue
|
|
190
|
+
total += 1
|
|
191
|
+
i = bisect.bisect_right(starts, t) - 1
|
|
192
|
+
bucket = per_group[groups[i] if i >= 0 else "before"]
|
|
193
|
+
bucket["n"] += 1
|
|
194
|
+
bucket["threads"][_thread_label(comm)] += 1
|
|
195
|
+
bucket["per_tid"][tid] += 1
|
|
196
|
+
bucket["tid_names"][tid] = comm
|
|
197
|
+
seen = set()
|
|
198
|
+
for depth, address in enumerate(frames):
|
|
199
|
+
resolved = cache.get(address)
|
|
200
|
+
if resolved is None:
|
|
201
|
+
resolved = cache[address] = resolve(address)
|
|
202
|
+
module, name = resolved
|
|
203
|
+
if depth == 0:
|
|
204
|
+
bucket["self"][name] += 1
|
|
205
|
+
bucket["modules"][module] += 1
|
|
206
|
+
bucket["self_tids"][name][tid] += 1
|
|
207
|
+
if name not in seen:
|
|
208
|
+
seen.add(name)
|
|
209
|
+
bucket["inclusive"][name] += 1
|
|
210
|
+
out = {"samples": total, "dropped": dropped_total, "period_s": period_s, "groups": {}}
|
|
211
|
+
for group, bucket in per_group.items():
|
|
212
|
+
n = bucket["n"] or 1
|
|
213
|
+
wall = wall_by_group.get(group, 0.0)
|
|
214
|
+
self_rows = []
|
|
215
|
+
for name, count in bucket["self"].most_common(top):
|
|
216
|
+
tids = bucket["self_tids"][name]
|
|
217
|
+
busiest = max(tids.values())
|
|
218
|
+
row = {"name": name[:96], "share": round(count / n, 4), "cpu_s": round(count * period_s, 3), "threads": len(tids),
|
|
219
|
+
# With all threads in this function at once, the time it holds up the run is the longest any one thread spent in it.
|
|
220
|
+
"busiest_thread_s": round(busiest * period_s, 3)}
|
|
221
|
+
if wall > 0:
|
|
222
|
+
row["pct_of_wall_on_busiest_thread"] = round(100 * busiest * period_s / wall, 1)
|
|
223
|
+
self_rows.append(row)
|
|
224
|
+
out["groups"][group] = {
|
|
225
|
+
"samples": bucket["n"],
|
|
226
|
+
"cpu_s": round(bucket["n"] * period_s, 3),
|
|
227
|
+
"modules": [{"name": k, "share": round(v / n, 4)} for k, v in bucket["modules"].most_common(8)],
|
|
228
|
+
"self": self_rows,
|
|
229
|
+
"inclusive": [{"name": k[:96], "share": round(v / n, 4)} for k, v in bucket["inclusive"].most_common(top)],
|
|
230
|
+
"threads": [{"name": k, "share": round(v / n, 4)} for k, v in bucket["threads"].most_common(6)],
|
|
231
|
+
"busiest_threads": [{"tid": tid, "name": bucket["tid_names"][tid], "cpu_s": round(count * period_s, 3)}
|
|
232
|
+
for tid, count in bucket["per_tid"].most_common(8)],
|
|
233
|
+
}
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _thread_label(name):
|
|
238
|
+
return "".join("#" if c.isdigit() else c for c in name)
|