Skip to content

Commit 5e34b22

Browse files
committed
feat(callgrind-utils): add perf_map symbolization for -X perf Python frames
Add the `perf_map` module: `CallGraph::symbolize_perf_map` resolves Callgrind's anonymous `0x...` JIT nodes to `py::<qualname>:<file>` via CPython's `/tmp/perf-<pid>.map`, written under `python3 -X perf`. Add the `fractal.py` fixture and `python_fractal_callgraph` test that profiles it live and snapshots the folded stacks and canonical JSON.
1 parent 9eb4ff1 commit 5e34b22

6 files changed

Lines changed: 709 additions & 0 deletions

File tree

callgrind-utils/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ pub mod error;
22
pub mod flamegraph;
33
pub mod model;
44
pub mod parser;
5+
pub mod perf_map;
56
mod redact;
67
pub mod serialize;

callgrind-utils/src/perf_map.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//! Symbolization of anonymous JIT frames via a `perf-<pid>.map` file.
2+
//!
3+
//! Callgrind emits anonymous JIT code (CPython's `-X perf` trampolines, V8, ...)
4+
//! as the literal absolute address `0x...`, leaving symbolization to the
5+
//! backend. CPython writes one trampoline per code object plus a
6+
//! `/tmp/perf-<pid>.map` line `<start-hex> <size-hex> py::<qualname>:<file>`, so
7+
//! an address that falls in a trampoline's range resolves to its Python name.
8+
9+
use std::io::BufRead;
10+
use std::path::Path;
11+
12+
use super::model::CallGraph;
13+
14+
/// A parsed `perf-<pid>.map`: half-open `[start, end)` address ranges, each
15+
/// mapped to a symbol, sorted by `start` for binary search.
16+
pub struct PerfMap {
17+
entries: Vec<(u64, u64, String)>,
18+
}
19+
20+
impl PerfMap {
21+
pub fn from_file(path: impl AsRef<Path>) -> std::io::Result<Self> {
22+
let file = std::fs::File::open(path)?;
23+
Ok(Self::from_reader(std::io::BufReader::new(file)))
24+
}
25+
26+
pub fn from_reader(reader: impl BufRead) -> Self {
27+
let mut entries: Vec<(u64, u64, String)> = reader
28+
.lines()
29+
.map_while(Result::ok)
30+
.filter_map(|line| parse_entry(&line))
31+
.collect();
32+
entries.sort_by_key(|(start, _, _)| *start);
33+
Self { entries }
34+
}
35+
36+
/// Resolve an address to its symbol, or `None` if it falls in no range.
37+
pub fn resolve(&self, addr: u64) -> Option<&str> {
38+
let index = self.entries.partition_point(|(start, _, _)| *start <= addr);
39+
let (start, end, name) = self.entries.get(index.checked_sub(1)?)?;
40+
(*start..*end).contains(&addr).then_some(name.as_str())
41+
}
42+
}
43+
44+
/// A perf-map line is `<start-hex> <size-hex> <symbol>`; anything else (blank
45+
/// lines, comments) is skipped.
46+
fn parse_entry(line: &str) -> Option<(u64, u64, String)> {
47+
let mut parts = line.splitn(3, ' ');
48+
let start = u64::from_str_radix(parts.next()?, 16).ok()?;
49+
let size = u64::from_str_radix(parts.next()?, 16).ok()?;
50+
let symbol = parts.next()?.trim();
51+
(!symbol.is_empty()).then(|| (start, start.wrapping_add(size), symbol.to_string()))
52+
}
53+
54+
impl CallGraph {
55+
/// Rename anonymous JIT nodes (`0x...`) to their `perf-<pid>.map` symbol.
56+
///
57+
/// The perf symbol embeds the source path (`py::fib:/abs/path/fractal.py`);
58+
/// the path is split into the node's `file` (basename only, so snapshots
59+
/// stay portable) and the `py::`-prefixed name stays as the function.
60+
pub fn symbolize_perf_map(self, map: &PerfMap) -> CallGraph {
61+
{
62+
let mut graph = self.inner.graph.borrow_mut();
63+
let node_indices: Vec<_> = graph.node_indices().collect();
64+
for idx in node_indices {
65+
let node = &mut graph[idx];
66+
// Callgrind appends a `'N` recursion marker (e.g. `0x1234'2`);
67+
// strip it to resolve the address, then re-attach it so the
68+
// marker survives on the resolved name like on native frames.
69+
let (base, cycle) = split_cycle_suffix(&node.name);
70+
let Some(addr) = parse_hex_address(base) else {
71+
continue;
72+
};
73+
let Some(symbol) = map.resolve(addr) else {
74+
continue;
75+
};
76+
let (name, file) = split_symbol_file(symbol);
77+
node.name = format!("{name}{cycle}");
78+
if let Some(file) = file {
79+
node.file = Some(file);
80+
}
81+
}
82+
}
83+
self
84+
}
85+
}
86+
87+
fn parse_hex_address(name: &str) -> Option<u64> {
88+
let hex = name.strip_prefix("0x")?;
89+
u64::from_str_radix(hex, 16).ok()
90+
}
91+
92+
/// Split a trailing Callgrind recursion marker `'<digits>` off a node name,
93+
/// returning (base, marker-including-quote). No marker yields an empty suffix.
94+
fn split_cycle_suffix(name: &str) -> (&str, &str) {
95+
let Some((base, digits)) = name.rsplit_once('\'') else {
96+
return (name, "");
97+
};
98+
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
99+
return (base, &name[base.len()..]);
100+
}
101+
(name, "")
102+
}
103+
104+
/// Split `py::<qualname>:<path>` into (`py::<qualname>`, basename of `<path>`).
105+
/// The trailing `:` separates the file, so split on the last one; a symbol
106+
/// without it (rare) keeps its name and gets no file.
107+
fn split_symbol_file(symbol: &str) -> (String, Option<String>) {
108+
let Some((name, path)) = symbol.rsplit_once(':') else {
109+
return (symbol.to_string(), None);
110+
};
111+
let base = path
112+
.rsplit('/')
113+
.next()
114+
.filter(|p| !p.is_empty())
115+
.unwrap_or(path);
116+
(name.to_string(), Some(base.to_string()))
117+
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# Python twin of `testdata/fractal.rs`: a self-contained copy of the CodSpeed
2+
# e2e Python benchmark (its `fractal.py` + `benchmark.py` merged), driven the
3+
# way CodSpeed drives a benchmark.
4+
#
5+
# Instrumentation is off at startup (run with --instr-atstart=no) and turned on
6+
# around the measured region via the `clgctl` shim, whose compiled path is
7+
# passed as argv[1]. The client requests fire several frames deep
8+
# (main -> run_benchmark -> warmup -> run_measured), mirroring the Rust twin, so
9+
# the seeder must reconstruct the native chain at the OFF->ON transition.
10+
#
11+
# Before starting, we skip the Python runtime objects (libpython + the python
12+
# executable) from Callgrind at runtime, exactly as pytest-codspeed's
13+
# instrument-hooks does in _callgrind_skip_python_runtime: the interpreter's own
14+
# C frames are folded into their callers so they don't obfuscate the graph.
15+
# Matching is by exact realpath, since Callgrind keys obj-skip on the mapped
16+
# object path.
17+
18+
import ctypes
19+
import math
20+
import os
21+
import sys
22+
import sysconfig
23+
from typing import Dict, List
24+
25+
clgctl = ctypes.CDLL(sys.argv[1])
26+
27+
# Benchmark workload parameters, matching the e2e `test_benchmark.py` /
28+
# `bench_fractal.rs` case: complex_fractal_benchmark(5, 3, 25).
29+
TREE_DEPTH = 5
30+
BRANCH_FACTOR = 3
31+
FIB_N = 25
32+
33+
34+
def skip_python_runtime():
35+
ldlibrary = sysconfig.get_config_var("LDLIBRARY")
36+
libdir = sysconfig.get_config_var("LIBDIR")
37+
libpython = next(
38+
(
39+
p
40+
for p in (
41+
os.path.join(libdir, ldlibrary) if ldlibrary and libdir else None,
42+
os.path.join(sys.prefix, "lib", ldlibrary) if ldlibrary else None,
43+
)
44+
if p and os.path.exists(p)
45+
),
46+
None,
47+
)
48+
for path in (libpython, sys.executable):
49+
if path:
50+
clgctl.clg_add_obj_skip(os.path.realpath(path).encode())
51+
52+
53+
class NodeMetadata:
54+
"""Metadata for a fractal node."""
55+
56+
def __init__(self, depth: int, branch_factor: int):
57+
self.depth = depth
58+
self.branch_factor = branch_factor
59+
self.computed_hash = 0
60+
61+
62+
class FractalNode:
63+
"""A node in a fractal computation tree."""
64+
65+
def __init__(self, value: float, depth: int, branch_factor: int):
66+
self.value = value
67+
self.children: List[FractalNode] = []
68+
self.metadata = NodeMetadata(depth, branch_factor)
69+
70+
@classmethod
71+
def build_fractal(
72+
cls, depth: int, max_depth: int, branch_factor: int, seed: float
73+
) -> "FractalNode":
74+
"""Recursively build a fractal tree with branching patterns."""
75+
node = cls(seed, depth, branch_factor)
76+
77+
if depth < max_depth:
78+
for i in range(branch_factor):
79+
child_seed = cls._compute_child_value(seed, i, depth)
80+
child = cls.build_fractal(depth + 1, max_depth, branch_factor, child_seed)
81+
node.children.append(child)
82+
83+
node.metadata.computed_hash = node.compute_tree_hash()
84+
return node
85+
86+
@staticmethod
87+
def _compute_child_value(parent_value: float, child_index: int, depth: int) -> float:
88+
"""Nested helper function to compute child values."""
89+
base = parent_value * 0.618033988749 # Golden ratio conjugate
90+
offset = (child_index + 1) * (depth + 1)
91+
return abs(math.sin(base + offset)) * 100.0
92+
93+
def compute_tree_hash(self) -> int:
94+
"""Recursively compute a hash of the entire tree structure."""
95+
hash_value = int(self.value * 1000)
96+
hash_value = (hash_value * 31 + self.metadata.depth) & 0xFFFFFFFFFFFFFFFF
97+
for child in self.children:
98+
child_hash = child.compute_tree_hash()
99+
hash_value = (hash_value * 31 + child_hash) & 0xFFFFFFFFFFFFFFFF
100+
return hash_value
101+
102+
def recursive_sum(self) -> float:
103+
"""Recursively compute the sum of all values in the tree."""
104+
children_sum = sum(child.recursive_sum() for child in self.children)
105+
return self.value + children_sum
106+
107+
def max_path_sum(self) -> float:
108+
"""Recursively find the maximum path sum from root to any leaf."""
109+
if not self.children:
110+
return self.value
111+
max_child_path = max(child.max_path_sum() for child in self.children)
112+
return self.value + max_child_path
113+
114+
def count_nodes(self) -> int:
115+
"""Recursively count all nodes in the tree."""
116+
return 1 + sum(child.count_nodes() for child in self.children)
117+
118+
def collect_leaves(self, leaves: List[float]) -> None:
119+
"""Recursively collect all leaf values."""
120+
if not self.children:
121+
leaves.append(self.value)
122+
else:
123+
for child in self.children:
124+
child.collect_leaves(leaves)
125+
126+
127+
class TreeAnalysis:
128+
"""Results of fractal tree analysis."""
129+
130+
def __init__(
131+
self,
132+
total_sum: float,
133+
node_count: int,
134+
max_path: float,
135+
leaf_variance: float,
136+
complexity_score: float,
137+
):
138+
self.total_sum = total_sum
139+
self.node_count = node_count
140+
self.max_path = max_path
141+
self.leaf_variance = leaf_variance
142+
self.complexity_score = complexity_score
143+
144+
145+
def fibonacci_memo(n: int, memo: Dict[int, int]) -> int:
146+
"""Compute Fibonacci with memoization (recursive with nested dict operations)."""
147+
if n <= 1:
148+
return n
149+
if n in memo:
150+
return memo[n]
151+
result = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
152+
memo[n] = result
153+
return result
154+
155+
156+
def compute_variance(values: List[float]) -> float:
157+
"""Nested helper to compute variance."""
158+
if not values:
159+
return 0.0
160+
mean = sum(values) / len(values)
161+
variance = sum((v - mean) ** 2 for v in values) / len(values)
162+
return variance
163+
164+
165+
def recursive_path_score(value: float, depth: int) -> float:
166+
"""Recursive helper for path scoring."""
167+
if depth == 0 or value < 1.0:
168+
return value
169+
reduced = value * 0.8
170+
return 1.0 + recursive_path_score(reduced, depth - 1) * 0.5
171+
172+
173+
def compute_complexity_score(node_count: int, variance: float, max_path: float) -> float:
174+
"""Nested helper to compute complexity score (with recursive internal call)."""
175+
base_score = math.log(node_count) * math.sqrt(variance)
176+
path_factor = recursive_path_score(max_path, 5)
177+
return base_score * path_factor
178+
179+
180+
def analyze_fractal_tree(tree: FractalNode, analysis_depth: int) -> TreeAnalysis:
181+
"""Nested function that analyzes the fractal tree with multiple passes."""
182+
total_sum = tree.recursive_sum()
183+
node_count = tree.count_nodes()
184+
max_path = tree.max_path_sum()
185+
186+
leaves: List[float] = []
187+
tree.collect_leaves(leaves)
188+
leaf_variance = compute_variance(leaves)
189+
190+
if analysis_depth > 0:
191+
nested_analysis = analyze_fractal_tree(tree, analysis_depth - 1)
192+
return TreeAnalysis(
193+
total_sum=total_sum + nested_analysis.total_sum * 0.1,
194+
node_count=node_count,
195+
max_path=max(max_path, nested_analysis.max_path),
196+
leaf_variance=(leaf_variance + nested_analysis.leaf_variance) / 2.0,
197+
complexity_score=compute_complexity_score(node_count, leaf_variance, max_path),
198+
)
199+
return TreeAnalysis(
200+
total_sum=total_sum,
201+
node_count=node_count,
202+
max_path=max_path,
203+
leaf_variance=leaf_variance,
204+
complexity_score=compute_complexity_score(node_count, leaf_variance, max_path),
205+
)
206+
207+
208+
def complex_fractal_benchmark(tree_depth: int, branch_factor: int, fib_n: int) -> float:
209+
"""Main benchmark: complex fractal tree computation."""
210+
tree = FractalNode.build_fractal(0, tree_depth, branch_factor, 42.0)
211+
analysis = analyze_fractal_tree(tree, 2)
212+
213+
memo: Dict[int, int] = {}
214+
fib_result = float(fibonacci_memo(fib_n, memo))
215+
216+
tree_hash = float(tree.compute_tree_hash())
217+
tree_metric = (
218+
analysis.total_sum
219+
+ (analysis.node_count * 10.0)
220+
+ analysis.max_path
221+
+ analysis.leaf_variance
222+
)
223+
return (tree_metric + fib_result + tree_hash) % 1_000_000.0
224+
225+
226+
# Deepest frame: instrumentation is turned on here, with
227+
# main -> run_benchmark -> warmup -> run_measured already live on the native
228+
# stack but the shadow stack empty. The seeder reconstructs that chain.
229+
def run_measured() -> float:
230+
clgctl.clg_start()
231+
result = complex_fractal_benchmark(TREE_DEPTH, BRANCH_FACTOR, FIB_N)
232+
clgctl.clg_stop()
233+
return result
234+
235+
236+
# Two unmeasured warmup iterations (instrumentation still off) before the
237+
# measured run, like a real benchmark harness.
238+
def warmup() -> float:
239+
acc = 0.0
240+
for _ in range(2):
241+
acc += complex_fractal_benchmark(TREE_DEPTH, BRANCH_FACTOR, FIB_N)
242+
return run_measured()
243+
244+
245+
def run_benchmark() -> float:
246+
return warmup()
247+
248+
249+
def main() -> None:
250+
skip_python_runtime()
251+
result = run_benchmark()
252+
assert 0 <= result < 1_000_000.0, result
253+
254+
255+
if __name__ == "__main__":
256+
main()

0 commit comments

Comments
 (0)