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,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])
@@ -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)