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