Skip to content

Commit aa6643d

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 ec3b691 commit aa6643d

6 files changed

Lines changed: 768 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: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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::collections::HashMap;
10+
use std::io::BufRead;
11+
use std::path::Path;
12+
13+
use super::model::{CallGraph, Node};
14+
15+
/// A parsed `perf-<pid>.map`: half-open `[start, end)` address ranges, each
16+
/// mapped to a symbol, sorted by `start` for binary search.
17+
pub struct PerfMap {
18+
entries: Vec<(u64, u64, String)>,
19+
}
20+
21+
impl PerfMap {
22+
pub fn from_file(path: impl AsRef<Path>) -> std::io::Result<Self> {
23+
let file = std::fs::File::open(path)?;
24+
Ok(Self::from_reader(std::io::BufReader::new(file)))
25+
}
26+
27+
pub fn from_reader(reader: impl BufRead) -> Self {
28+
let mut entries: Vec<(u64, u64, String)> = reader
29+
.lines()
30+
.map_while(Result::ok)
31+
.filter_map(|line| parse_entry(&line))
32+
.collect();
33+
entries.sort_by_key(|(start, _, _)| *start);
34+
Self { entries }
35+
}
36+
37+
/// Resolve an address to its symbol, or `None` if it falls in no range.
38+
pub fn resolve(&self, addr: u64) -> Option<&str> {
39+
let index = self.entries.partition_point(|(start, _, _)| *start <= addr);
40+
let (start, end, name) = self.entries.get(index.checked_sub(1)?)?;
41+
(*start..*end).contains(&addr).then_some(name.as_str())
42+
}
43+
}
44+
45+
/// A perf-map line is `<start-hex> <size-hex> <symbol>`; anything else (blank
46+
/// lines, comments) is skipped.
47+
fn parse_entry(line: &str) -> Option<(u64, u64, String)> {
48+
let mut parts = line.splitn(3, ' ');
49+
let start = u64::from_str_radix(parts.next()?, 16).ok()?;
50+
let size = u64::from_str_radix(parts.next()?, 16).ok()?;
51+
let symbol = parts.next()?.trim();
52+
(!symbol.is_empty()).then(|| (start, start.wrapping_add(size), symbol.to_string()))
53+
}
54+
55+
impl CallGraph {
56+
/// Rename anonymous JIT nodes (`0x...`) to their `perf-<pid>.map` symbol.
57+
///
58+
/// The perf symbol embeds the source path (`py::fib:/abs/path/fractal.py`);
59+
/// the path is split into the node's `file` (basename only, so snapshots
60+
/// stay portable) and the `py::`-prefixed name stays as the function.
61+
pub fn symbolize_perf_map(self, map: &PerfMap) -> CallGraph {
62+
let CallGraph {
63+
mut nodes,
64+
mut edges,
65+
self_costs,
66+
} = self;
67+
68+
// Self costs are re-keyed onto the symbolized identities, summing where
69+
// distinct addresses collapse to the same resolved name.
70+
let mut self_cost_map: HashMap<Node, u64> = HashMap::new();
71+
for (node, &cost) in nodes.iter().zip(self_costs.iter()) {
72+
let mut symbolized = node.clone();
73+
symbolize_node(&mut symbolized, map);
74+
*self_cost_map.entry(symbolized).or_insert(0) += cost;
75+
}
76+
77+
for node in &mut nodes {
78+
symbolize_node(node, map);
79+
}
80+
for edge in &mut edges {
81+
symbolize_node(&mut edge.caller, map);
82+
symbolize_node(&mut edge.callee, map);
83+
}
84+
85+
CallGraph::from_parts(nodes, edges, self_cost_map)
86+
}
87+
}
88+
89+
/// Rename an anonymous JIT node (`0x...`) to its `perf-<pid>.map` symbol.
90+
///
91+
/// The perf symbol embeds the source path (`py::fib:/abs/path/fractal.py`);
92+
/// the path is split into the node's `file` (basename only, so snapshots stay
93+
/// portable) and the `py::`-prefixed name stays as the function. Nodes whose
94+
/// function is not a resolvable address are left untouched.
95+
fn symbolize_node(node: &mut Node, map: &PerfMap) {
96+
// Callgrind appends a `'N` recursion marker (e.g. `0x1234'2`); strip it to
97+
// resolve the address, then re-attach it so the marker survives on the
98+
// resolved name like on native frames.
99+
let (base, cycle) = split_cycle_suffix(&node.function);
100+
let Some(addr) = parse_hex_address(base) else {
101+
return;
102+
};
103+
let Some(symbol) = map.resolve(addr) else {
104+
return;
105+
};
106+
let (name, file) = split_symbol_file(symbol);
107+
node.function = format!("{name}{cycle}");
108+
if let Some(file) = file {
109+
node.file = file;
110+
}
111+
}
112+
113+
fn parse_hex_address(name: &str) -> Option<u64> {
114+
let hex = name.strip_prefix("0x")?;
115+
u64::from_str_radix(hex, 16).ok()
116+
}
117+
118+
/// Split a trailing Callgrind recursion marker `'<digits>` off a node name,
119+
/// returning (base, marker-including-quote). No marker yields an empty suffix.
120+
fn split_cycle_suffix(name: &str) -> (&str, &str) {
121+
let Some((base, digits)) = name.rsplit_once('\'') else {
122+
return (name, "");
123+
};
124+
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
125+
return (base, &name[base.len()..]);
126+
}
127+
(name, "")
128+
}
129+
130+
/// Split `py::<qualname>:<path>` into (`py::<qualname>`, basename of `<path>`).
131+
/// The trailing `:` separates the file, so split on the last one; a symbol
132+
/// without it (rare) keeps its name and gets no file.
133+
fn split_symbol_file(symbol: &str) -> (String, Option<String>) {
134+
let Some((name, path)) = symbol.rsplit_once(':') else {
135+
return (symbol.to_string(), None);
136+
};
137+
let base = path
138+
.rsplit('/')
139+
.next()
140+
.filter(|p| !p.is_empty())
141+
.unwrap_or(path);
142+
(name.to_string(), Some(base.to_string()))
143+
}

0 commit comments

Comments
 (0)