From 8c37d7022861ee948669141ebc5051ba92b0fb05 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 16:42:20 +0200 Subject: [PATCH 01/10] feat(callgrind-utils): parse .out into a call graph and emit canonical JSON New Rust crate (edition 2024) that reads a Callgrind .out profile and extracts call-graph topology (costs/addresses ignored), serializing to canonical index-ref JSON for stable cross-platform callgraph diffing. Node identity is the {object,file,function} tuple so same-named statics stay distinct. Edges are emitted only on calls= lines (cl-format.xml CallSpec); name compression across three ID spaces, the cfl/cfi alias, inline fi/fe callee-context inheritance, and multi-part merge are handled. 18 integration tests; clippy and rustfmt clean. --- callgrind-utils/.gitignore | 1 + callgrind-utils/Cargo.lock | 128 ++++++++++ callgrind-utils/Cargo.toml | 9 + callgrind-utils/src/error.rs | 23 ++ callgrind-utils/src/lib.rs | 5 + callgrind-utils/src/model.rs | 135 +++++++++++ callgrind-utils/src/normalize.rs | 28 +++ callgrind-utils/src/parser.rs | 286 ++++++++++++++++++++++ callgrind-utils/src/serialize.rs | 51 ++++ callgrind-utils/tests/data/example.out | 126 ++++++++++ callgrind-utils/tests/parser.rs | 314 +++++++++++++++++++++++++ 11 files changed, 1106 insertions(+) create mode 100644 callgrind-utils/.gitignore create mode 100644 callgrind-utils/Cargo.lock create mode 100644 callgrind-utils/Cargo.toml create mode 100644 callgrind-utils/src/error.rs create mode 100644 callgrind-utils/src/lib.rs create mode 100644 callgrind-utils/src/model.rs create mode 100644 callgrind-utils/src/normalize.rs create mode 100644 callgrind-utils/src/parser.rs create mode 100644 callgrind-utils/src/serialize.rs create mode 100644 callgrind-utils/tests/data/example.out create mode 100644 callgrind-utils/tests/parser.rs diff --git a/callgrind-utils/.gitignore b/callgrind-utils/.gitignore new file mode 100644 index 000000000..9f970225a --- /dev/null +++ b/callgrind-utils/.gitignore @@ -0,0 +1 @@ +target/ \ No newline at end of file diff --git a/callgrind-utils/Cargo.lock b/callgrind-utils/Cargo.lock new file mode 100644 index 000000000..0b2ca8bef --- /dev/null +++ b/callgrind-utils/Cargo.lock @@ -0,0 +1,128 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "callgrind-utils" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/callgrind-utils/Cargo.toml b/callgrind-utils/Cargo.toml new file mode 100644 index 000000000..5fcf9edb8 --- /dev/null +++ b/callgrind-utils/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "callgrind-utils" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/callgrind-utils/src/error.rs b/callgrind-utils/src/error.rs new file mode 100644 index 000000000..87f849779 --- /dev/null +++ b/callgrind-utils/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +/// Errors raised while parsing a Callgrind `.out` file. +#[derive(Debug, Error)] +pub enum ParseError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("bad id: {0}")] + BadId(#[from] std::num::ParseIntError), + #[error("call record missing required cfn=")] + MissingCfn, + #[error("unexpected end of input")] + UnexpectedEof, +} + +/// Errors raised while serializing a `CallGraph` to JSON. +#[derive(Debug, Error)] +pub enum ToJsonError { + #[error("serde error: {0}")] + Serde(#[from] serde_json::Error), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} diff --git a/callgrind-utils/src/lib.rs b/callgrind-utils/src/lib.rs new file mode 100644 index 000000000..162b2d680 --- /dev/null +++ b/callgrind-utils/src/lib.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod model; +mod normalize; +pub mod parser; +pub mod serialize; diff --git a/callgrind-utils/src/model.rs b/callgrind-utils/src/model.rs new file mode 100644 index 000000000..be32961a9 --- /dev/null +++ b/callgrind-utils/src/model.rs @@ -0,0 +1,135 @@ +use std::collections::HashMap; + +use serde::Serialize; + +/// A call-graph node: a single function identity. +/// +/// Node identity is the full `(object, file, function)` tuple, so two +/// statics that share a name but live in different objects/files are +/// distinct nodes (no false merge). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct Node { + pub function: String, + pub file: String, + pub object: String, +} + +/// A directed call edge: `caller` calls `callee`, optionally annotated +/// with an observed `call_count`. +/// +/// `Edge` deliberately does NOT derive `Serialize`: the canonical JSON +/// view references nodes by index, not by value. See `serialize::EdgeJson`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edge { + pub caller: Node, + pub callee: Node, + pub call_count: Option, +} + +/// Tunables for `.out` parsing. +#[derive(Debug, Clone)] +pub struct ParseOptions { + /// When true, file/object paths are reduced to their basename and + /// Callgrind-style unknowns (`???`) collapse to `unknown`. + pub normalize_paths: bool, + /// Sentinel substituted for absent/unknown object or file names. + pub unknown: String, +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { + normalize_paths: true, + unknown: "???".to_string(), + } + } +} + +/// The parsed call graph: sorted, deduplicated nodes and edges. +/// +/// Fields are `pub(crate)` so the sibling `parser` and `serialize` +/// modules can materialize/consume them without exposing them publicly. +pub struct CallGraph { + pub(crate) nodes: Vec, + pub(crate) edges: Vec, +} + +impl CallGraph { + /// Borrow the sorted node list. + pub fn nodes(&self) -> &[Node] { + &self.nodes + } + + /// Borrow the sorted, deduplicated edge list. + pub fn edges(&self) -> &[Edge] { + &self.edges + } + + /// Construct a `CallGraph` from raw parsed material. + /// + /// Nodes are sorted by `(object, file, function)` and de-duplicated. + /// Edges are sorted by `(caller_idx, callee_idx)` using the sorted + /// node order, then de-duplicated by `(caller, callee)`, aggregating + /// `call_count` across duplicates (sum when both are `Some`; keep the + /// first value when any duplicate is `None`). + pub(crate) fn from_parts(mut nodes: Vec, mut edges: Vec) -> Self { + nodes.sort_by(|a, b| { + a.object + .cmp(&b.object) + .then_with(|| a.file.cmp(&b.file)) + .then_with(|| a.function.cmp(&b.function)) + }); + nodes.dedup(); + + // Index lookup for stable node ordering of edges. + let mut index: HashMap<&Node, usize> = HashMap::with_capacity(nodes.len()); + for (i, n) in nodes.iter().enumerate() { + index.insert(n, i); + } + + let edge_rank = |e: &Edge| { + ( + index.get(&e.caller).copied().unwrap_or(usize::MAX), + index.get(&e.callee).copied().unwrap_or(usize::MAX), + ) + }; + edges.sort_by_key(edge_rank); + + // Dedup adjacent (now grouped) edges, aggregating call_count. + let mut deduped: Vec = Vec::with_capacity(edges.len()); + for e in edges { + // Dedup adjacent (now grouped) duplicate edges, summing counts; + // any None keeps the first value as-is. + if let Some(last) = deduped.last_mut() + && last.caller == e.caller + && last.callee == e.callee + { + if let (Some(a), Some(b)) = (last.call_count, e.call_count) { + last.call_count = Some(a + b); + } + continue; + } + deduped.push(e); + } + + Self { + nodes, + edges: deduped, + } + } + + /// Index of `n` within the sorted node list, or `None` if absent. + /// + /// Uses binary search over the `(object, file, function)` ordering + /// established by `from_parts`. + pub(crate) fn node_index(&self, n: &Node) -> Option { + self.nodes + .binary_search_by(|x| { + x.object + .cmp(&n.object) + .then_with(|| x.file.cmp(&n.file)) + .then_with(|| x.function.cmp(&n.function)) + }) + .ok() + } +} diff --git a/callgrind-utils/src/normalize.rs b/callgrind-utils/src/normalize.rs new file mode 100644 index 000000000..ca3044ecd --- /dev/null +++ b/callgrind-utils/src/normalize.rs @@ -0,0 +1,28 @@ +use super::model::ParseOptions; + +/// Return the last path segment after the final `/`. +/// +/// `"foo/bar/baz.c"` -> `"baz.c"`; `"baz.c"` -> `"baz.c"`; `""` -> `""`. +pub(crate) fn basename(path: &str) -> &str { + match path.rfind('/') { + Some(i) => &path[i + 1..], + None => path, + } +} + +/// Normalize a file/object path according to `opts`. +/// +/// When `normalize_paths` is disabled the path is returned verbatim. +/// Otherwise the basename is taken and Callgrind-style unknowns (empty or +/// `"???"`) collapse to `opts.unknown`. +pub(crate) fn normalize_path(path: &str, opts: &ParseOptions) -> String { + if !opts.normalize_paths { + return path.to_string(); + } + let leaf = basename(path); + if leaf.is_empty() || leaf == "???" { + opts.unknown.clone() + } else { + leaf.to_string() + } +} diff --git a/callgrind-utils/src/parser.rs b/callgrind-utils/src/parser.rs new file mode 100644 index 000000000..d20408e2f --- /dev/null +++ b/callgrind-utils/src/parser.rs @@ -0,0 +1,286 @@ +use std::collections::HashMap; + +use super::{ + error::ParseError, + model::{CallGraph, Edge, Node, ParseOptions}, + normalize, +}; + +/// Header/auxiliary keys that carry no call-graph topology and are dropped +/// outright. `part`/`thread` are handled separately (context boundaries), +/// not here. `cfni` is an inline-function annotation, not a callee spec. +const SKIP_KEYS: &[&str] = &[ + "version", + "creator", + "pid", + "cmd", + "desc", + "positions", + "events", + "event", + "summary", + "totals", + "rec", + "jfi", + "jfn", + "frfn", + "cfni", + "jump", + "jcnd", +]; + +impl CallGraph { + /// Parse a Callgrind `.out` stream into a call graph. + /// + /// The format is line-oriented (see `callgrind/docs/cl-format.xml`). We + /// track three independent name-compression ID spaces (functions, files, + /// objects), the current caller context, and a pending callee record. + /// An edge is emitted only when a `calls=` line closes a record that has a + /// pending `cfn=`; a bare `cfn=` is callee context that gets discarded. + pub fn parse(reader: impl std::io::BufRead, opts: &ParseOptions) -> Result { + // Three SEPARATE name-compression ID spaces. + let mut fn_ids: HashMap = HashMap::new(); + let mut file_ids: HashMap = HashMap::new(); + let mut obj_ids: HashMap = HashMap::new(); + + // Current caller context. + let mut cur_obj: Option = None; + let mut cur_fl: Option = None; // the function's own file (`fl=`) + let mut cur_pos_file: Option = None; // current position file (`fl`/`fi`/`fe`) + let mut cur_fn: Option = None; + + // Pending callee record, built from `cob`/`cfi`/`cfl`/`cfn`. + let mut pend_cob: Option = None; + let mut pend_cfi: Option = None; + let mut pend_cfn: Option = None; + + let mut nodes: Vec = Vec::new(); + let mut edges: Vec = Vec::new(); + + for line in reader.lines() { + let line = line?; // io error -> ParseError::Io (#[from]) + let trimmed = line.trim_start(); + + // Blank lines and comments carry nothing. + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let key = line_key(trimmed); + + // `part:`/`thread:` separators bound a record: clear the pending + // callee, but keep the ID maps and caller context (IDs persist + // across parts; parts/threads are always merged into one graph). + if key == "part" || key == "thread" { + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + continue; + } + + // Header/auxiliary lines carry no topology. Body-level skips + // (`jump`/`jcnd`/`jfi`/`jfn`/`cfni`/`frfn`) must ALSO close any open + // call record, so a bare `cfn=` cannot survive across them and + // poison a later `calls=`. Clearing when nothing is pending is a + // harmless no-op for true header lines. + if SKIP_KEYS.contains(&key) { + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + continue; + } + + // Position specs and `calls` are `key=value`; a colon-separated + // (`ob:`) or bare token is a header/cost/unknown line, never a spec. + let assign = trimmed.as_bytes().get(key.len()) == Some(&b'='); + + // A `calls=` line closes a call record and emits the edge. + if key == "calls" && assign { + if let Some(cfn) = pend_cfn.take() { + let rhs = &trimmed[key.len() + 1..]; + let call_count = parse_call_count(rhs); + + // Caller file is the function's own `fl` (cur_fl), NEVER the + // current position file: an inline `fi=`/`fe=` transition + // moves the callee context but not the caller's identity. + let caller = make_node( + cur_fn.as_deref(), + cur_fl.as_deref(), + cur_obj.as_deref(), + opts, + ); + // Callee inherits the current position file (which may be an + // inline `fi`/`fe` file) and the caller object unless the + // record overrode them with `cfi`/`cfl`/`cob`. + let callee_file = pend_cfi.as_deref().or(cur_pos_file.as_deref()); + let callee_obj = pend_cob.as_deref().or(cur_obj.as_deref()); + let callee = make_node(Some(cfn.as_str()), callee_file, callee_obj, opts); + + nodes.push(caller.clone()); + nodes.push(callee.clone()); + edges.push(Edge { + caller, + callee, + call_count, + }); + } + // Whether or not an edge was emitted, the record is closed. + pend_cob = None; + pend_cfi = None; + continue; + } + + // Lines lacking an `=` after the key — colon headers (`ob:`), bare + // tokens, and cost/address lines — are never specs or calls, so + // they only close any open call record (a bare `cfn=` thus cannot + // poison a later `calls=`). + if !assign { + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + continue; + } + + // Recognized position specs dispatch below; an unknown `key=value` + // falls to the `_` arm, which also closes the record. + match key { + "ob" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut obj_ids)?; + cur_obj = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "fl" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut file_ids)?; + cur_fl = Some(x.clone()); + cur_pos_file = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "fi" | "fe" => { + // Inline-file transition: moves the position file only, not + // the function's own `fl`. + let x = parse_pos_name(rhs_of(trimmed, key), &mut file_ids)?; + cur_pos_file = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "fn" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut fn_ids)?; + cur_fn = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "cob" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut obj_ids)?; + pend_cob = Some(x); + } + "cfi" | "cfl" => { + // `cfl` is the historical alias of `cfi`; identical meaning. + let x = parse_pos_name(rhs_of(trimmed, key), &mut file_ids)?; + pend_cfi = Some(x); + } + "cfn" => { + // Do NOT clear pend_cob/pend_cfi: they legitimately precede + // cfn within the same call record. + let x = parse_pos_name(rhs_of(trimmed, key), &mut fn_ids)?; + pend_cfn = Some(x); + } + _ => { + // Cost/subposition lines and anything unrecognized close any + // dangling callee context. + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + } + } + + // Nothing to flush at EOF: a bare trailing `cfn=` is discarded. + Ok(CallGraph::from_parts(nodes, edges)) + } +} + +/// The leading token of `line`: everything up to the first `=`, `:`, or +/// whitespace. For `fn=(1) main` this is `"fn"`; for `0x401000 4`, `"0x401000"`. +fn line_key(line: &str) -> &str { + let end = line + .find(|c: char| c == '=' || c == ':' || c.is_whitespace()) + .unwrap_or(line.len()); + &line[..end] +} + +/// The value after `key=` in a position-spec line. Callers only invoke this for +/// keys known to be followed by `=`, so the separator byte is skipped directly. +fn rhs_of<'a>(trimmed: &'a str, key: &str) -> &'a str { + &trimmed[key.len() + 1..] +} + +/// Resolve a name-compression RHS against its ID map. +/// +/// `(N) name` defines ID `N` -> `name` and returns the name; `(N)` references a +/// previously defined ID; a bare `name` (compression off) is returned verbatim +/// and never touches the map. +fn parse_pos_name(rhs: &str, map: &mut HashMap) -> Result { + let rhs = rhs.trim_start(); + let Some(after_paren) = rhs.strip_prefix('(') else { + // Compression off: literal name. + return Ok(rhs.trim().to_owned()); + }; + + // The entire substring before `)` is the numeric ID; everything after it + // (split on the FIRST `)`, so names may themselves contain `)`) is the + // optional name. An unterminated `(N` treats the remainder as the ID. + let (num, rest) = after_paren.split_once(')').unwrap_or((after_paren, "")); + let id: u32 = num.trim().parse()?; // non-numeric/empty id -> ParseError::BadId + let name = rest.trim(); + + if name.is_empty() { + // Reference: resolve the prior definition (empty if unknown; the + // normalizer maps empties to opts.unknown for files/objects). + Ok(map.get(&id).cloned().unwrap_or_default()) + } else { + map.insert(id, name.to_owned()); + Ok(name.to_owned()) + } +} + +/// First token after `calls=`, parsed as a decimal or `0x`-hex count. +fn parse_call_count(rhs: &str) -> Option { + let tok = rhs.split_whitespace().next()?; + match tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X")) { + Some(hex) => u64::from_str_radix(hex, 16).ok(), + None => tok.parse::().ok(), + } +} + +/// Build a node. The function name keeps its raw text; file and object are +/// normalized (basename + unknown handling per `opts`). Absent/empty file and +/// object default to `opts.unknown` BEFORE normalizing so that disabling +/// `normalize_paths` cannot leave a blank node key. +fn make_node( + function: Option<&str>, + file: Option<&str>, + object: Option<&str>, + opts: &ParseOptions, +) -> Node { + let or_unknown = |v: Option<&str>| { + normalize::normalize_path( + v.filter(|s| !s.is_empty()).unwrap_or(opts.unknown.as_str()), + opts, + ) + }; + let function = match function { + Some(f) if !f.is_empty() => f.to_owned(), + _ => opts.unknown.clone(), + }; + Node { + function, + file: or_unknown(file), + object: or_unknown(object), + } +} diff --git a/callgrind-utils/src/serialize.rs b/callgrind-utils/src/serialize.rs new file mode 100644 index 000000000..b28b8816f --- /dev/null +++ b/callgrind-utils/src/serialize.rs @@ -0,0 +1,51 @@ +use serde::Serialize; + +use super::{ + error::ToJsonError, + model::{CallGraph, Node}, +}; + +/// Canonical JSON view of the whole graph: nodes inline, edges by index. +#[derive(Serialize)] +struct GraphJson<'a> { + nodes: &'a [Node], + edges: Vec, +} + +/// JSON view of a single edge: caller/callee as node indices. +/// +/// `call_count` is omitted from the output when `None`. +#[derive(Serialize)] +struct EdgeJson { + caller: usize, + callee: usize, + #[serde(skip_serializing_if = "Option::is_none")] + call_count: Option, +} + +impl CallGraph { + /// Serialize the graph to a canonical pretty-printed JSON string. + pub fn to_json(&self) -> Result { + let edges: Vec = self + .edges() + .iter() + .map(|e| EdgeJson { + caller: self.node_index(&e.caller).expect("caller node present"), + callee: self.node_index(&e.callee).expect("callee node present"), + call_count: e.call_count, + }) + .collect(); + let graph = GraphJson { + nodes: self.nodes(), + edges, + }; + serde_json::to_string_pretty(&graph) + } + + /// Serialize the graph to a JSON file at `path`. + pub fn to_json_file(&self, path: impl AsRef) -> Result<(), ToJsonError> { + let s = self.to_json()?; + std::fs::write(path, s)?; + Ok(()) + } +} diff --git a/callgrind-utils/tests/data/example.out b/callgrind-utils/tests/data/example.out new file mode 100644 index 000000000..fa47c84b8 --- /dev/null +++ b/callgrind-utils/tests/data/example.out @@ -0,0 +1,126 @@ +# callgrind format +version: 1 +creator: callgrind-fixture +pid: 1 +cmd: ./prog +desc: I1 cache +desc: D1 cache +positions: line +events: Ir +summary: 1000 +totals: 1000 + +# ===== Part 1 ===== +# Header / context lines: ob=, fl=, fn= define compressed IDs 1 for each space. +# Object ID 1 = /path/to/clreq ; File ID 1 = file1.c ; Function ID 1 = main. +ob=(1) /path/to/clreq +fl=(1) file1.c +fn=(1) main + +# --- main body: cost/subposition lines using +N / * / -N / 0x... (all ignored) --- +0x401000 4 ++5 8 +* 3 +-2 1 + +# --- two-line call spec: cfn=(2) func1 / calls=1 50 / cost 16 400 --- +# Defines Function ID 2 = func1. The cost line (16 400) is present but ignored. +cfn=(2) func1 +calls=1 50 +16 400 + +# --- cfl= alias equals cfi= for a callee file spec --- +# cfl=(5) cflfile.c defines File ID 5 = cflfile.c and sets the callee file. +cfl=(5) cflfile.c +cfn=cflop +calls=1 52 +18 30 + +# --- cfni= inline function line: ignored for topology (no node/edge created) --- +cfni=(7) some_inline +# --- omitted cfi/cfl: callee inherits the CURRENT file context (file1.c here) --- +cfn=nofile +calls=1 53 +19 10 + +# --- same function name in two different objects/files -> TWO distinct nodes --- +# helper in liba/fileA.c . Object ID 2 = liba ; File ID 2 = fileA.c ; Function ID 4 = helper. +cob=(2) liba +cfi=(2) fileA.c +cfn=(4) helper +calls=1 60 +20 5 +# helper in libb/fileB.c (same name, different object+file -> distinct node). +# cfn=(4) is a REFERENCE reusing Function ID 4 = helper. +cob=(3) libb +cfi=(3) fileB.c +cfn=(4) +calls=1 61 +21 5 + +# --- cob= overrides caller object (callee in extlib, file inherited from context) --- +# Object ID 4 = extlib . No cfi -> callee inherits current file (file1.c). +cob=(4) extlib +cfn=extfn +calls=1 70 +22 3 + +# --- switch caller context to func1 (fn=(2) REFERENCE -> resolves to func1) --- +fl=(1) +fn=(2) +ob=(1) +# func1 calls func2 . Defines Function ID 3 = func2 . +cfn=(3) func2 +calls=1 54 +23 100 + +# --- switch caller context to func2 (fn=(3) REFERENCE) --- +fl=(1) +fn=(3) +ob=(1) +# func2 calls rec . Defines Function ID 5 = rec . +cfn=(5) rec +calls=1 55 +24 50 +# func2 calls func1 : cfn=(2) REFERENCE resolves to func1 (name compression reuse). +cfn=(2) +calls=1 62 +25 20 + +# --- switch caller context to rec (fn=(5) REFERENCE); recursion -> self-edge --- +fl=(1) +fn=(5) +ob=(1) +cfn=(5) +calls=1 56 +26 7 + +# --- inline fi=/fe= file transition BEFORE a call with no cfi --- +# inlhost fl=file1.c . fi=(6) inline.c switches the CURRENT file context to inline.c. +# The call below has NO cfi/cfl, so the callee inherits inline.c (NOT the fl file1.c). +fl=(1) file1.c +fn=inlhost +ob=(1) +fi=(6) inline.c +cfn=inltarget +calls=1 57 +27 4 +# fe=(1) switches the current file context back to the function file (file1.c). +fe=(1) + +# ===== Part 2: multi-part merge (ID maps persist across parts) ===== +part: 2 +# References resolve via the persistent ID maps: +# fl=(1) -> file1.c , fn=(1) -> main , ob=(1) -> /path/to/clreq +fl=(1) +fn=(1) +ob=(1) +# main calls part2fn : this edge only appears in part 2 and must merge into the graph. +cfn=part2fn +calls=1 100 +29 2 +# bare cfn= with NO calls= line -> NO edge. Per cl-format.xml, CallSpec requires a +# calls= line (CallLine); cfn= alone only sets callee context and is discarded. The +# "28 1" below is a self-cost line of main (ignored). `nocnt` must NOT become a node. +cfn=nocnt +28 1 diff --git a/callgrind-utils/tests/parser.rs b/callgrind-utils/tests/parser.rs new file mode 100644 index 000000000..60652fe16 --- /dev/null +++ b/callgrind-utils/tests/parser.rs @@ -0,0 +1,314 @@ +//! Integration tests for the Callgrind `.out` -> call-graph parser. +//! +//! Exercises the real format shapes from `callgrind/docs/cl-format.xml` and +//! `callgrind/dump.c`: two-line call specs, name compression `(N)`, the +//! `cfl`/`cfi` alias, callee file/object inheritance (including inline +//! `fi`/`fe` transitions), same-named functions in distinct objects, direct +//! recursion, multi-part merge, and the canonical JSON projection. + +use callgrind_utils::model::{CallGraph, Edge, Node, ParseOptions}; +use std::io::Cursor; + +const FIXTURE: &str = include_str!("data/example.out"); + +fn parse_default() -> CallGraph { + CallGraph::parse(Cursor::new(FIXTURE), &ParseOptions::default()).expect("parse fixture") +} + +/// All edges whose caller and callee function names match. +fn edges_fn<'a>(g: &'a CallGraph, caller: &str, callee: &str) -> Vec<&'a Edge> { + g.edges() + .iter() + .filter(|e| e.caller.function == caller && e.callee.function == callee) + .collect() +} + +/// All nodes with the given function name (distinct by object/file). +fn nodes_fn<'a>(g: &'a CallGraph, function: &str) -> Vec<&'a Node> { + g.nodes() + .iter() + .filter(|n| n.function == function) + .collect() +} + +#[test] +fn parses_basic_callgraph() { + let g = parse_default(); + // 12 distinct nodes, 12 edges (see fixture; `nocnt` is discarded, no edge). + assert_eq!(g.nodes().len(), 12, "nodes: {:#?}", g.nodes()); + assert_eq!(g.edges().len(), 12, "edges: {:#?}", g.edges()); + + let mf1 = edges_fn(&g, "main", "func1"); + assert_eq!(mf1.len(), 1); + assert_eq!(mf1[0].call_count, Some(1)); + assert_eq!(mf1[0].caller.file, "file1.c"); + assert_eq!(mf1[0].callee.file, "file1.c"); +} + +#[test] +fn resolves_name_compression() { + // `fn=(1)`/`fl=(1)`/`ob=(1)` references must resolve to their defs. + let g = parse_default(); + let main = nodes_fn(&g, "main"); + assert_eq!(main.len(), 1); + assert_eq!(main[0].file, "file1.c"); + assert_eq!(main[0].object, "clreq"); + // func2 -> func1 uses `cfn=(2)` as a *reference* to the earlier def. + assert_eq!(edges_fn(&g, "func2", "func1").len(), 1); +} + +#[test] +fn cfl_alias_equals_cfi() { + // `cfl=(5) cflfile.c` is the historical alias of `cfi=`; the callee file + // must resolve to cflfile.c. + let g = parse_default(); + let e = edges_fn(&g, "main", "cflop"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].callee.file, "cflfile.c"); + assert_eq!(e[0].callee.object, "clreq"); +} + +#[test] +fn omitted_cfi_inherits_current_file_context() { + // No `cfi`/`cfl`: the callee inherits the CURRENT position file, NOT the + // caller's original `fl`. For `nofile` the context is still file1.c. + let g = parse_default(); + let e = edges_fn(&g, "main", "nofile"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].callee.file, "file1.c"); +} + +#[test] +fn inline_fi_fe_changes_callee_context_not_caller() { + // CRITICAL: after `fi=(6) inline.c`, a `cfn=` with no `cfi` makes the + // CALLEE inherit inline.c, while the CALLER (inlhost) keeps its own `fl` + // (file1.c). Pins both halves: caller file != callee file here. + let g = parse_default(); + let inlhost = nodes_fn(&g, "inlhost"); + assert_eq!(inlhost.len(), 1); + assert_eq!( + inlhost[0].file, "file1.c", + "caller keeps its fl, not the inline file" + ); + + let e = edges_fn(&g, "inlhost", "inltarget"); + assert_eq!(e.len(), 1); + assert_eq!( + e[0].callee.file, "inline.c", + "callee inherits the inline context" + ); + assert_eq!(e[0].caller.file, "file1.c"); +} + +#[test] +fn same_name_different_object_are_distinct() { + // `helper` exists in liba/fileA.c AND libb/fileB.c -> two distinct nodes, + // two distinct edges from main. + let g = parse_default(); + let helpers = nodes_fn(&g, "helper"); + assert_eq!(helpers.len(), 2, "helpers: {helpers:#?}"); + + let mut keys: Vec<(&str, &str)> = helpers + .iter() + .map(|n| (n.object.as_str(), n.file.as_str())) + .collect(); + keys.sort(); + assert_eq!(keys, vec![("liba", "fileA.c"), ("libb", "fileB.c")]); + + assert_eq!(edges_fn(&g, "main", "helper").len(), 2); +} + +#[test] +fn recursion_becomes_self_edge() { + let g = parse_default(); + let rec = edges_fn(&g, "rec", "rec"); + assert_eq!(rec.len(), 1); + assert_eq!(rec[0].caller, rec[0].callee); +} + +#[test] +fn cob_overrides_caller_object() { + // `cob=(4) extlib` with no `cfi`: callee object is extlib, file inherited + // from caller context (file1.c). + let g = parse_default(); + let e = edges_fn(&g, "main", "extfn"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].callee.object, "extlib"); + assert_eq!(e[0].callee.file, "file1.c"); + assert_eq!(e[0].caller.object, "clreq"); +} + +#[test] +fn multi_part_merged() { + // The `part: 2` section's `main -> part2fn` edge must merge into one graph. + let g = parse_default(); + assert_eq!(edges_fn(&g, "main", "part2fn").len(), 1); +} + +#[test] +fn bare_cfn_without_calls_is_discarded() { + // `cfn=nocnt` with no `calls=` line is callee context only, not a call + // record (cl-format.xml: CallSpec requires a CallLine). No node, no edge. + let g = parse_default(); + assert!(nodes_fn(&g, "nocnt").is_empty(), "nocnt must not be a node"); + assert!(edges_fn(&g, "main", "nocnt").is_empty(), "no edge to nocnt"); +} + +#[test] +fn every_edge_has_a_call_count() { + // With the calls=-required rule, every emitted edge carries Some(count). + let g = parse_default(); + for e in g.edges() { + assert!(e.call_count.is_some(), "edge {e:?} should have a count"); + } +} + +#[test] +fn costs_and_addresses_ignored() { + // Subposition/cost lines (+N, *, -N, 0x..., "16 400") never create nodes. + // Node count stays at the 12 real functions. + let g = parse_default(); + assert_eq!(g.nodes().len(), 12); + assert!(!g.nodes().iter().any(|n| n.function.starts_with("0x"))); +} + +#[test] +fn paths_normalized_by_default() { + // Default opts: object path `/path/to/clreq` -> basename `clreq`. + let g = parse_default(); + assert!(g.nodes().iter().any(|n| n.object == "clreq")); + assert!( + !g.nodes().iter().any(|n| n.object.contains('/')), + "no object should retain a path separator" + ); +} + +#[test] +fn paths_verbatim_when_normalization_off() { + let opts = ParseOptions { + normalize_paths: false, + ..Default::default() + }; + let g = CallGraph::parse(Cursor::new(FIXTURE), &opts).expect("parse"); + assert!( + g.nodes().iter().any(|n| n.object == "/path/to/clreq"), + "object path must be kept verbatim: {:#?}", + g.nodes() + ); +} + +#[test] +fn to_json_is_canonical() { + let g = parse_default(); + let json = g.to_json().expect("to_json"); + let v: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + + let nodes = v["nodes"].as_array().expect("nodes array"); + let edges = v["edges"].as_array().expect("edges array"); + assert_eq!(nodes.len(), 12); + assert_eq!(edges.len(), 12); + + // Nodes sorted by (object, file, function). + let key = |n: &serde_json::Value| { + ( + n["object"].as_str().unwrap().to_owned(), + n["file"].as_str().unwrap().to_owned(), + n["function"].as_str().unwrap().to_owned(), + ) + }; + let mut sorted = nodes.clone(); + sorted.sort_by_key(key); + assert_eq!(nodes, &sorted, "nodes must be pre-sorted"); + + // Edges reference nodes by valid index; call_count present (never None here). + for e in edges { + let c = e["caller"].as_u64().unwrap() as usize; + let d = e["callee"].as_u64().unwrap() as usize; + assert!( + c < nodes.len() && d < nodes.len(), + "edge index out of range" + ); + assert!( + e.get("call_count").is_some(), + "call_count present for fixture edges" + ); + } + + // Edges sorted by (caller_idx, callee_idx). + let pairs: Vec<(u64, u64)> = edges + .iter() + .map(|e| (e["caller"].as_u64().unwrap(), e["callee"].as_u64().unwrap())) + .collect(); + let mut sorted_pairs = pairs.clone(); + sorted_pairs.sort(); + assert_eq!( + pairs, sorted_pairs, + "edges must be pre-sorted by index pair" + ); +} + +#[test] +fn to_json_omits_none_call_count() { + // Construct via parse, then confirm the serializer would omit a None count + // by checking the field is absent only when the value is None. All fixture + // edges have Some, so every edge object must carry call_count. + let g = parse_default(); + let json = g.to_json().expect("to_json"); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + for e in v["edges"].as_array().unwrap() { + assert!(e.get("call_count").is_some()); + } +} + +#[test] +fn bare_cfn_does_not_poison_next_edge() { + // A bare `cfn=unused` (cleared by the following self-cost line) must not + // become the callee of a later `calls=` that has its own `cfn=`. + let out = "\ +# callgrind format +events: Ir +ob=(1) prog +fl=(1) a.c +fn=(1) caller +cfn=(2) unused +5 3 +cfn=(3) realcallee +calls=2 10 +6 4 +"; + let g = CallGraph::parse(Cursor::new(out), &ParseOptions::default()).expect("parse"); + assert!( + nodes_fn(&g, "unused").is_empty(), + "bare cfn must be discarded" + ); + let e = edges_fn(&g, "caller", "realcallee"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].call_count, Some(2)); + assert!(edges_fn(&g, "caller", "unused").is_empty()); +} + +#[test] +fn bare_cfn_does_not_survive_jump_line() { + // A `jump=`/`jcnd=` line between a bare `cfn=` and a `calls=` must clear the + // pending callee, so the `calls=` (lacking its own `cfn=`) emits no edge. + let out = "\ +# callgrind format +events: Ir +ob=(1) prog +fl=(1) a.c +fn=(1) caller +cfn=(2) unused +jump=3 10 +calls=2 11 +6 4 +"; + let g = CallGraph::parse(Cursor::new(out), &ParseOptions::default()).expect("parse"); + assert!( + nodes_fn(&g, "unused").is_empty(), + "jump must clear the pending cfn" + ); + assert!( + g.edges().is_empty(), + "calls= had no live cfn after the jump -> no edge" + ); +} From 3aef1364222f4676c4af2433057203c3367a8315 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 17:32:40 +0200 Subject: [PATCH 02/10] test(callgrind-utils): add valgrind-driven fixture snapshot tests Add testdata/*.c fixtures (recursion, chain, diamond, mutual) profiled by the in-repo Callgrind through an rstest harness that compiles each fixture and runs vg-in-place, then snapshots the canonical JSON. --instr-atstart=no plus the fixtures' client requests keep loader/libc frames out, so the JSON is stable across platforms. --- callgrind-utils/Cargo.lock | 412 ++++++++++++++++++ callgrind-utils/Cargo.toml | 4 + callgrind-utils/testdata/chain.c | 27 ++ callgrind-utils/testdata/diamond.c | 32 ++ callgrind-utils/testdata/mutual.c | 26 ++ callgrind-utils/testdata/recursion.c | 40 ++ callgrind-utils/tests/snapshot.rs | 98 +++++ .../snapshots/snapshot__chain__json.snap | 45 ++ .../snapshots/snapshot__diamond__json.snap | 60 +++ .../snapshots/snapshot__mutual__json.snap | 60 +++ .../snapshots/snapshot__recursion__json.snap | 60 +++ 11 files changed, 864 insertions(+) create mode 100644 callgrind-utils/testdata/chain.c create mode 100644 callgrind-utils/testdata/diamond.c create mode 100644 callgrind-utils/testdata/mutual.c create mode 100644 callgrind-utils/testdata/recursion.c create mode 100644 callgrind-utils/tests/snapshot.rs create mode 100644 callgrind-utils/tests/snapshots/snapshot__chain__json.snap create mode 100644 callgrind-utils/tests/snapshots/snapshot__diamond__json.snap create mode 100644 callgrind-utils/tests/snapshots/snapshot__mutual__json.snap create mode 100644 callgrind-utils/tests/snapshots/snapshot__recursion__json.snap diff --git a/callgrind-utils/Cargo.lock b/callgrind-utils/Cargo.lock index 0b2ca8bef..798087006 100644 --- a/callgrind-utils/Cargo.lock +++ b/callgrind-utils/Cargo.lock @@ -2,27 +2,261 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "callgrind-utils" version = "0.1.0" dependencies = [ + "insta", + "rstest", "serde", "serde_json", "thiserror", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -41,6 +275,105 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -84,6 +417,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "syn" version = "2.0.118" @@ -95,6 +440,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -115,12 +473,66 @@ dependencies = [ "syn", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/callgrind-utils/Cargo.toml b/callgrind-utils/Cargo.toml index 5fcf9edb8..f9fff0c70 100644 --- a/callgrind-utils/Cargo.toml +++ b/callgrind-utils/Cargo.toml @@ -7,3 +7,7 @@ edition = "2024" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" + +[dev-dependencies] +insta = "1" +rstest = "0.23" diff --git a/callgrind-utils/testdata/chain.c b/callgrind-utils/testdata/chain.c new file mode 100644 index 000000000..cb9360263 --- /dev/null +++ b/callgrind-utils/testdata/chain.c @@ -0,0 +1,27 @@ +// Fixture: a linear call chain `main -> a -> b -> c` (no recursion, no shared +// callees). See recursion.c for the instrumentation/build conventions. + +#include + +static int c(int n) { + return n + 1; +} + +static int b(int n) { + return c(n) + 1; +} + +static int a(int n) { + return b(n) + 1; +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = a(5); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/testdata/diamond.c b/callgrind-utils/testdata/diamond.c new file mode 100644 index 000000000..d617b2759 --- /dev/null +++ b/callgrind-utils/testdata/diamond.c @@ -0,0 +1,32 @@ +// Fixture: a diamond graph where `bottom` is a shared callee reached via two +// paths: `main -> top -> {left, right} -> bottom`. Exercises a node with two +// distinct incoming edges. See recursion.c for the conventions. + +#include + +static int bottom(int n) { + return n * 2; +} + +static int left(int n) { + return bottom(n) + 1; +} + +static int right(int n) { + return bottom(n) + 2; +} + +static int top(int n) { + return left(n) + right(n); +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = top(5); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/testdata/mutual.c b/callgrind-utils/testdata/mutual.c new file mode 100644 index 000000000..0afc3013f --- /dev/null +++ b/callgrind-utils/testdata/mutual.c @@ -0,0 +1,26 @@ +// Fixture: mutual recursion `is_even <-> is_odd`, forming a two-function cycle +// reached from `main`. Exercises cyclic call topology. See recursion.c for the +// instrumentation/build conventions. + +#include + +static int is_odd(int n); + +static int is_even(int n) { + return n == 0 ? 1 : is_odd(n - 1); +} + +static int is_odd(int n) { + return n == 0 ? 0 : is_even(n - 1); +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = is_even(6); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/testdata/recursion.c b/callgrind-utils/testdata/recursion.c new file mode 100644 index 000000000..e27cfa00e --- /dev/null +++ b/callgrind-utils/testdata/recursion.c @@ -0,0 +1,40 @@ +// Fixture for callgrind-utils snapshot tests. +// +// A small, pure-compute call graph: direct recursion (`fib` -> `fib`) plus two +// helper edges (`compute` -> `fib`, `compute` -> `square`) under `main`. +// +// Mirrors how CodSpeed drives a benchmark: instrumentation is off at startup +// (run with `--instr-atstart=no`), so loader/libc-start frames are excluded, +// then turned on around the measured region. Build with `-g -O0` so the +// functions are real (no inlining) and carry debug names. +// +// Requires the in-repo Callgrind client-request header: +// cc -g -O0 -I callgrind -I include ... + +#include + +static int fib(int n) { + if (n < 2) { + return n; + } + return fib(n - 1) + fib(n - 2); +} + +static int square(int n) { + return n * n; +} + +static int compute(int n) { + return fib(n) + square(n); +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = compute(8); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/tests/snapshot.rs b/callgrind-utils/tests/snapshot.rs new file mode 100644 index 000000000..35f43fd5a --- /dev/null +++ b/callgrind-utils/tests/snapshot.rs @@ -0,0 +1,98 @@ +//! Golden snapshot tests over the `testdata/*.c` fixtures. +//! +//! Each case compiles its fixture and profiles it with the in-repo Callgrind +//! (`vg-in-place`, expected at the repo root), then snapshots the parsed +//! canonical JSON. The fixtures run with +//! `--instr-atstart=no` (plus client requests) and `--obj-skip`, so the graph +//! is just their own functions and the JSON is stable across platforms. +//! +//! These tests require a built `./vg-in-place` at the repo root. +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use callgrind_utils::model::{CallGraph, ParseOptions}; +use rstest::rstest; + +/// Repo root: this crate lives at `/callgrind-utils`. +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crate has a parent directory") + .to_path_buf() +} + +fn vg_in_place() -> PathBuf { + let path = repo_root().join("vg-in-place"); + assert!( + path.is_file(), + "vg-in-place not found at {} - build Valgrind in place first", + path.display() + ); + path +} + +/// Compile `testdata/.c` into this test binary's temp dir. `-O0` keeps the +/// functions un-inlined and `-g` gives them debug names; `callgrind.h` pulls in +/// `valgrind.h` via `-I include`. +fn compile_fixture(stem: &str) -> PathBuf { + let repo = repo_root(); + let src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{stem}.c")); + let bin = Path::new(env!("CARGO_TARGET_TMPDIR")).join(stem); + + let status = Command::new("cc") + .args(["-g", "-O0"]) + .arg("-I") + .arg(repo.join("callgrind")) + .arg("-I") + .arg(repo.join("include")) + .arg("-o") + .arg(&bin) + .arg(&src) + .status() + .unwrap_or_else(|e| panic!("failed to spawn cc for {stem}: {e}")); + assert!( + status.success(), + "cc failed for {} ({status})", + src.display() + ); + bin +} + +/// Profile `bin` with the in-repo Callgrind and return the `.out` contents. +/// +/// `--instr-atstart=no` (paired with the fixture's client requests) excludes the +/// loader/libc-start frames; `--obj-skip` drops the libc/ld frames the shadow- +/// stack seeder reconstructs, leaving just the fixture's own functions. +fn run_callgrind(bin: &Path) -> String { + let out_file = bin.with_extension("callgrind.out"); + let status = Command::new(vg_in_place()) + .arg("--tool=callgrind") + .arg("--instr-atstart=no") + .arg("--obj-skip=*libc*") + .arg("--obj-skip=*ld-*") + .arg(format!("--callgrind-out-file={}", out_file.display())) + .arg(bin) + .status() + .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); + assert!(status.success()); + std::fs::read_to_string(&out_file) + .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) +} + +#[rstest] +#[case("recursion")] +#[case("chain")] +#[case("diamond")] +#[case("mutual")] +fn fixture_canonical_json(#[case] stem: &str) { + let bin = compile_fixture(stem); + let raw = run_callgrind(&bin); + let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) + .unwrap_or_else(|e| panic!("parse {stem} callgrind output: {e:?}")); + let json = graph.to_json().expect("to_json"); + + insta::assert_snapshot!(format!("{stem}__json"), json); +} diff --git a/callgrind-utils/tests/snapshots/snapshot__chain__json.snap b/callgrind-utils/tests/snapshots/snapshot__chain__json.snap new file mode 100644 index 000000000..a83ab0a98 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__chain__json.snap @@ -0,0 +1,45 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "a", + "file": "chain.c", + "object": "chain" + }, + { + "function": "b", + "file": "chain.c", + "object": "chain" + }, + { + "function": "c", + "file": "chain.c", + "object": "chain" + }, + { + "function": "main", + "file": "chain.c", + "object": "chain" + } + ], + "edges": [ + { + "caller": 0, + "callee": 1, + "call_count": 1 + }, + { + "caller": 1, + "callee": 2, + "call_count": 1 + }, + { + "caller": 3, + "callee": 0, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__diamond__json.snap b/callgrind-utils/tests/snapshots/snapshot__diamond__json.snap new file mode 100644 index 000000000..f6a366448 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__diamond__json.snap @@ -0,0 +1,60 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "bottom", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "left", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "main", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "right", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "top", + "file": "diamond.c", + "object": "diamond" + } + ], + "edges": [ + { + "caller": 1, + "callee": 0, + "call_count": 1 + }, + { + "caller": 2, + "callee": 4, + "call_count": 1 + }, + { + "caller": 3, + "callee": 0, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 4, + "callee": 3, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__mutual__json.snap b/callgrind-utils/tests/snapshots/snapshot__mutual__json.snap new file mode 100644 index 000000000..ccffb29a7 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__mutual__json.snap @@ -0,0 +1,60 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "is_even", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_even'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "main", + "file": "mutual.c", + "object": "mutual" + } + ], + "edges": [ + { + "caller": 0, + "callee": 2, + "call_count": 1 + }, + { + "caller": 1, + "callee": 3, + "call_count": 2 + }, + { + "caller": 2, + "callee": 1, + "call_count": 1 + }, + { + "caller": 3, + "callee": 1, + "call_count": 2 + }, + { + "caller": 4, + "callee": 0, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__recursion__json.snap b/callgrind-utils/tests/snapshots/snapshot__recursion__json.snap new file mode 100644 index 000000000..8ba0bf9a9 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__recursion__json.snap @@ -0,0 +1,60 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "compute", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib'2", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "main", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "square", + "file": "recursion.c", + "object": "recursion" + } + ], + "edges": [ + { + "caller": 0, + "callee": 1, + "call_count": 1 + }, + { + "caller": 0, + "callee": 4, + "call_count": 1 + }, + { + "caller": 1, + "callee": 2, + "call_count": 2 + }, + { + "caller": 2, + "callee": 2, + "call_count": 64 + }, + { + "caller": 3, + "callee": 0, + "call_count": 1 + } + ] +} From 9e775a79dca91a36d58dc9461711f9dfdfa564f5 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 16:00:10 +0000 Subject: [PATCH 03/10] fix(VEX): classify arm64 plain B as Ijk_Boring, not Ijk_Call The AArch64 B{L} decoder tagged the whole opcode group as Ijk_Call, but only BL (bit 31 = 1, writes the link register) is a call; a plain B (bit 31 = 0) is an ordinary unconditional branch. Mislabelling B as a call made Callgrind treat every branch to a function epilogue or tail target as a call. At -O0 a conditional like `return n < 2 ? n : fib(...)` compiles the base case to `b `, so each base case was counted as a recursive call -- inflating recursive/cyclic call graphs and inventing phantom self-edges on arm64 (e.g. fib recursion 64 -> 98; mutual is_even/is_odd gaining self-loops). Align plain B with B.cond and the register-indirect JMP, which already use Ijk_Boring. Fixes the callgrind-utils recursion/mutual snapshot failures. Co-Authored-By: Claude Opus 4.8 --- VEX/priv/guest_arm64_toIR.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/VEX/priv/guest_arm64_toIR.c b/VEX/priv/guest_arm64_toIR.c index 6e77b34c7..62927537c 100644 --- a/VEX/priv/guest_arm64_toIR.c +++ b/VEX/priv/guest_arm64_toIR.c @@ -7422,7 +7422,7 @@ Bool dis_ARM64_branch_etc(/*MB_OUT*/DisResult* dres, UInt insn, /* -------------------- B{L} uncond -------------------- */ if (INSN(30,26) == BITS5(0,0,1,0,1)) { /* 000101 imm26 B (PC + sxTo64(imm26 << 2)) - 100101 imm26 B (PC + sxTo64(imm26 << 2)) + 100101 imm26 BL (PC + sxTo64(imm26 << 2)) */ UInt bLink = INSN(31,31); ULong uimm64 = INSN(25,0) << 2; @@ -7432,7 +7432,11 @@ Bool dis_ARM64_branch_etc(/*MB_OUT*/DisResult* dres, UInt insn, } putPC(mkU64(guest_PC_curr_instr + simm64)); dres->whatNext = Dis_StopHere; - dres->jk_StopHere = Ijk_Call; + /* Only BL (which writes the link register) is a call; a plain B is + an ordinary unconditional branch. Mislabelling B as Ijk_Call makes + callgrind treat every branch to a function epilogue / tail target as + a call, corrupting recursive and cyclic call graphs on arm64. */ + dres->jk_StopHere = bLink ? Ijk_Call : Ijk_Boring; DIP("b%s 0x%llx\n", bLink == 1 ? "l" : "", guest_PC_curr_instr + simm64); return True; From 11a0bc186d97527822579b9e357de39f1940c378 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 16:27:26 +0000 Subject: [PATCH 04/10] test(callgrind-utils): add --instr-atstart=yes full-trace matrix Add a fixture_full_trace rstest matrix over the same four fixtures, traced with --instr-atstart=yes so the whole program (loader, libc startup, main's own entry) is captured, not just the client-request scoped region. The startup frames carry non-portable names (__libc_start_main@@GLIBC_2.34, raw loader addresses), so this asserts version-stable invariants rather than a golden snapshot: JSON round-trips, main appears as a callee (full-program capture), the fixture's own functions are present, and the per-fixture call shape matches the scoped snapshots. The recursion count (fib'2->fib'2 == 64) and mutual no-self-edge checks double as regression guards for the arm64 B-vs-BL jump-kind fix. Co-Authored-By: Claude Opus 4.8 --- callgrind-utils/tests/snapshot.rs | 144 ++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/callgrind-utils/tests/snapshot.rs b/callgrind-utils/tests/snapshot.rs index 35f43fd5a..85dd7710d 100644 --- a/callgrind-utils/tests/snapshot.rs +++ b/callgrind-utils/tests/snapshot.rs @@ -96,3 +96,147 @@ fn fixture_canonical_json(#[case] stem: &str) { insta::assert_snapshot!(format!("{stem}__json"), json); } + +/// Profile `bin` with Callgrind instrumenting from process start +/// (`--instr-atstart=yes`). +/// +/// Unlike `run_callgrind`, this captures the whole program: the loader, libc +/// startup, and `main`'s own entry, not just the client-request-scoped compute +/// region. The fixture's `CALLGRIND_ZERO_STATS` still zeroes the pre-`main` +/// edges (they end up with `calls=0`), but `main` now appears as a *callee* of +/// the startup frames — the defining difference from the scoped run. +/// +/// No `--obj-skip` here: we want the startup frames present so the +/// full-program-capture invariant is observable. +fn run_callgrind_full(bin: &Path) -> String { + let out_file = bin.with_extension("full.callgrind.out"); + let status = Command::new(vg_in_place()) + .arg("--tool=callgrind") + .arg("--instr-atstart=yes") + .arg(format!("--callgrind-out-file={}", out_file.display())) + .arg(bin) + .status() + .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); + assert!(status.success()); + std::fs::read_to_string(&out_file) + .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) +} + +/// Strip Callgrind's recursion-separation suffix (`fib'2` -> `fib`) so a +/// function and its deeper-recursion clones compare equal. +fn base(name: &str) -> &str { + name.split('\'').next().unwrap_or(name) +} + +/// Sum the `call_count` over every edge with these exact caller/callee +/// function names (recursion clones are distinct names, so pass them exactly). +fn sum_counts(g: &CallGraph, caller: &str, callee: &str) -> u64 { + g.edges() + .iter() + .filter(|e| e.caller.function == caller && e.callee.function == callee) + .map(|e| e.call_count.unwrap_or(0)) + .sum() +} + +/// True if any edge connects these two functions, regardless of count. +fn has_edge(g: &CallGraph, caller: &str, callee: &str) -> bool { + g.edges() + .iter() + .any(|e| e.caller.function == caller && e.callee.function == callee) +} + +/// Full-program trace matrix: profile each fixture with `--instr-atstart=yes` +/// and assert version-stable invariants rather than a golden snapshot (the +/// startup frames carry libc/loader-specific names like +/// `__libc_start_main@@GLIBC_2.34` and raw loader addresses, which are not +/// portable). This is both an end-to-end test of full-program tracing and a +/// regression guard for the arm64 `B`-vs-`BL` jump-kind fix. +#[rstest] +#[case("recursion")] +#[case("chain")] +#[case("diamond")] +#[case("mutual")] +fn fixture_full_trace(#[case] stem: &str) { + let bin = compile_fixture(stem); + let raw = run_callgrind_full(&bin); + let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) + .unwrap_or_else(|e| panic!("parse {stem} full callgrind output: {e:?}")); + + // The canonical JSON projection round-trips on a large, real-world graph. + graph.to_json().expect("to_json"); + + // Full-program capture: `main` is reached as a callee. The scoped run never + // observes this (instrumentation starts inside `main`). + assert!( + graph.edges().iter().any(|e| e.callee.function == "main"), + "{stem}: expected `main` to appear as a callee under --instr-atstart=yes" + ); + + // The fixture's own source functions all appear in the binary's object + // (which also carries CRT glue like `_start`/`frame_dummy` — hence subset, + // not equality). This is the same self-contained sub-graph as the scoped + // snapshot, now surrounded by — but not merged with — the startup frames. + let own: Vec<&str> = graph + .nodes() + .iter() + .filter(|n| n.object == stem) + .map(|n| n.function.as_str()) + .collect(); + let expected: &[&str] = match stem { + "recursion" => &["compute", "fib", "fib'2", "main", "square"], + "chain" => &["a", "b", "c", "main"], + "diamond" => &["bottom", "left", "main", "right", "top"], + "mutual" => &["is_even", "is_even'2", "is_odd", "is_odd'2", "main"], + _ => unreachable!("unknown fixture {stem}"), + }; + for f in expected { + assert!( + own.contains(f), + "{stem}: expected fixture function `{f}` in object `{stem}`; got {own:?}" + ); + } + + // Per-fixture call-graph shape (matches the committed scoped snapshots; the + // ZERO_STATS-bounded compute region carries identical counts). + match stem { + "recursion" => { + // Direct recursion: fib(8) makes 2*fib(9)-1 = 67 fib invocations. + // compute->fib=1, fib->fib'2=2, leaving fib'2->fib'2=64. The pre-fix + // arm64 bug inflated this to 98 via phantom base-case "calls". + assert_eq!(sum_counts(&graph, "fib", "fib'2"), 2, "recursion fib->fib'2"); + assert_eq!( + sum_counts(&graph, "fib'2", "fib'2"), + 64, + "recursion fib'2->fib'2" + ); + } + "chain" => { + assert_eq!(sum_counts(&graph, "main", "a"), 1, "chain main->a"); + assert_eq!(sum_counts(&graph, "a", "b"), 1, "chain a->b"); + assert_eq!(sum_counts(&graph, "b", "c"), 1, "chain b->c"); + } + "diamond" => { + assert!(has_edge(&graph, "top", "left"), "diamond top->left"); + assert!(has_edge(&graph, "top", "right"), "diamond top->right"); + assert!(has_edge(&graph, "left", "bottom"), "diamond left->bottom"); + assert!(has_edge(&graph, "right", "bottom"), "diamond right->bottom"); + } + "mutual" => { + // is_even/is_odd are *mutually* recursive: neither ever calls itself. + // The arm64 B-vs-BL bug invented self-edges (is_even->is_even'2 etc.); + // guard against their return. + for e in graph.edges() { + let (cb, kb) = (base(&e.caller.function), base(&e.callee.function)); + if cb == "is_even" || cb == "is_odd" { + assert_ne!(cb, kb, "mutual: spurious self-edge {e:?}"); + } + } + assert!(has_edge(&graph, "is_even", "is_odd"), "mutual is_even->is_odd"); + assert!( + has_edge(&graph, "is_odd", "is_even'2"), + "mutual is_odd->is_even'2" + ); + } + _ => unreachable!("unknown fixture {stem}"), + } +} From 83d030a3d7705d330f4931926d6dcc6d6673abb7 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 18:30:44 +0200 Subject: [PATCH 05/10] test(callgrind-utils): add Python fixture with runtime obj-skip Profile a Python workload (recursion.py) live under the in-repo Callgrind, mirroring pytest-codspeed: a ctypes-loaded shim (clgctl.c) fires CALLGRIND_START/STOP and adds libpython + the python executable to the obj-skip list at runtime via CALLGRIND_ADD_OBJ_SKIP. Callgrind never names Python-level frames, so the test asserts structure rather than a golden snapshot: the START shim is captured and the Python runtime is folded out. --- callgrind-utils/testdata/clgctl.c | 28 ++++ callgrind-utils/testdata/recursion.py | 59 ++++++++ callgrind-utils/tests/python_callgraph.rs | 174 ++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 callgrind-utils/testdata/clgctl.c create mode 100644 callgrind-utils/testdata/recursion.py create mode 100644 callgrind-utils/tests/python_callgraph.rs diff --git a/callgrind-utils/testdata/clgctl.c b/callgrind-utils/testdata/clgctl.c new file mode 100644 index 000000000..3deb59341 --- /dev/null +++ b/callgrind-utils/testdata/clgctl.c @@ -0,0 +1,28 @@ +// Callgrind client-request shim for the Python fixture (`recursion.py`). +// +// The CALLGRIND_* client requests are inline-asm sequences, so they can't be +// issued from pure Python. The Python fixture loads this shared library via +// `ctypes` and calls these entry points to drive instrumentation, mirroring +// what pytest-codspeed's instrument-hooks does: skip the Python runtime objects +// at runtime, then START/ZERO around the measured region and STOP after. +// +// Build (shared, against the in-repo client-request headers): +// cc -g -O0 -shared -fPIC -I callgrind -I include ... + +#include + +// Add an object file to Callgrind's obj-skip list at runtime. Matching is exact +// against the mapped object path, so the caller passes a realpath (same as +// instrument-hooks' `callgrind_add_obj_skip`). +void clg_add_obj_skip(const char *path) { + CALLGRIND_ADD_OBJ_SKIP(path); +} + +void clg_start(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; +} + +void clg_stop(void) { + CALLGRIND_STOP_INSTRUMENTATION; +} diff --git a/callgrind-utils/testdata/recursion.py b/callgrind-utils/testdata/recursion.py new file mode 100644 index 000000000..b9c7f0433 --- /dev/null +++ b/callgrind-utils/testdata/recursion.py @@ -0,0 +1,59 @@ +# Python counterpart to recursion.c: the same fib/square/compute shape, driven +# the way CodSpeed drives a benchmark. Instrumentation is off at startup (run +# with --instr-atstart=no) and turned on around the measured region via the +# clgctl shim, whose compiled path is passed as argv[1]. +# +# Before starting, we skip the Python runtime objects (libpython + the python +# executable) from Callgrind at runtime, exactly as pytest-codspeed's +# instrument-hooks does in _callgrind_skip_python_runtime: the interpreter's own +# C frames are folded into their callers so they don't obfuscate the graph. +# Matching is by exact realpath, since Callgrind keys obj-skip on the mapped +# object path. +import ctypes +import os +import sys +import sysconfig + +clgctl = ctypes.CDLL(sys.argv[1]) + + +def skip_python_runtime(): + ldlibrary = sysconfig.get_config_var("LDLIBRARY") + libdir = sysconfig.get_config_var("LIBDIR") + libpython = next( + ( + p + for p in ( + os.path.join(libdir, ldlibrary) if ldlibrary and libdir else None, + os.path.join(sys.prefix, "lib", ldlibrary) if ldlibrary else None, + ) + if p and os.path.exists(p) + ), + None, + ) + for path in (libpython, sys.executable): + if path: + clgctl.clg_add_obj_skip(os.path.realpath(path).encode()) + + +def fib(n): + if n < 2: + return n + return fib(n - 1) + fib(n - 2) + + +def square(n): + return n * n + + +def compute(n): + return fib(n) + square(n) + + +skip_python_runtime() + +clgctl.clg_start() +sink = compute(8) +clgctl.clg_stop() + +assert sink == 85, sink diff --git a/callgrind-utils/tests/python_callgraph.rs b/callgrind-utils/tests/python_callgraph.rs new file mode 100644 index 000000000..dadb109c1 --- /dev/null +++ b/callgrind-utils/tests/python_callgraph.rs @@ -0,0 +1,174 @@ +//! Structural test over the Python fixture (`testdata/recursion.py`). +//! +//! Unlike the C snapshot tests, a live Python run can't be a golden snapshot: +//! Callgrind sees the CPython interpreter's C frames (version- and +//! platform-specific), never the Python functions. The fixture drives Callgrind +//! the way pytest-codspeed does: at runtime it adds the Python runtime objects +//! (libpython + the python executable) to the obj-skip list via the +//! `CALLGRIND_ADD_OBJ_SKIP` client request, so the interpreter's own frames are +//! folded into their callers and don't obfuscate the graph. This test profiles +//! the fixture live and asserts structural properties: instrumentation started +//! at the shim, and the runtime obj-skip removed the interpreter. +//! +//! Requires a built `./vg-in-place` at the repo root and `cc`. Silently skips +//! when `python3` is not on PATH (mirrors the `.vgtest` `prereq` guards). +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use callgrind_utils::model::{CallGraph, ParseOptions}; + +/// Repo root: this crate lives at `/callgrind-utils`. +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crate has a parent directory") + .to_path_buf() +} + +fn vg_in_place() -> PathBuf { + let path = repo_root().join("vg-in-place"); + assert!( + path.is_file(), + "vg-in-place not found at {} - build Valgrind in place first", + path.display() + ); + path +} + +fn have_python3() -> bool { + Command::new("python3") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// The basenames of the objects `recursion.py` adds to the obj-skip list: +/// libpython and the python executable, resolved exactly as the fixture does +/// (realpath, then basename, matching Callgrind's normalized object names). +fn skipped_runtime_objects() -> Vec { + let script = "\ +import os, sys, sysconfig +ld = sysconfig.get_config_var('LDLIBRARY') +libdir = sysconfig.get_config_var('LIBDIR') +cands = [os.path.join(libdir, ld) if ld and libdir else None, + os.path.join(sys.prefix, 'lib', ld) if ld else None] +lp = next((p for p in cands if p and os.path.exists(p)), None) +for p in (lp, sys.executable): + if p: + print(os.path.basename(os.path.realpath(p))) +"; + let out = Command::new("python3") + .arg("-c") + .arg(script) + .output() + .expect("run python3 to resolve runtime objects"); + assert!(out.status.success(), "python3 obj resolution failed"); + String::from_utf8(out.stdout) + .expect("utf8") + .lines() + .map(str::to_owned) + .collect() +} + +/// Compile the Callgrind client-request shim the Python fixture loads via +/// `ctypes`, as a shared library against the in-repo `callgrind.h`. +fn compile_clgctl() -> PathBuf { + let repo = repo_root(); + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/clgctl.c"); + let lib = Path::new(env!("CARGO_TARGET_TMPDIR")).join("libclgctl.so"); + + let status = Command::new("cc") + .args(["-g", "-O0", "-shared", "-fPIC"]) + .arg("-I") + .arg(repo.join("callgrind")) + .arg("-I") + .arg(repo.join("include")) + .arg("-o") + .arg(&lib) + .arg(&src) + .status() + .unwrap_or_else(|e| panic!("failed to spawn cc for clgctl: {e}")); + assert!( + status.success(), + "cc failed for {} ({status})", + src.display() + ); + lib +} + +/// Profile `testdata/recursion.py` with the in-repo Callgrind and return the +/// `.out` contents. `--instr-atstart=no` pairs with the shim's client requests +/// so only the measured region is profiled; the fixture adds the obj-skips +/// itself, so no `--obj-skip` is passed on the command line. +fn run_python(clgctl: &Path) -> String { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/recursion.py"); + let out_file = Path::new(env!("CARGO_TARGET_TMPDIR")).join("python.callgrind.out"); + + let status = Command::new(vg_in_place()) + .arg("--tool=callgrind") + .arg("--instr-atstart=no") + .arg(format!("--callgrind-out-file={}", out_file.display())) + .arg("python3") + .arg(&script) + .arg(clgctl) + .status() + .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); + assert!(status.success(), "vg-in-place exited with {status}"); + std::fs::read_to_string(&out_file).unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) +} + +#[test] +fn python_runtime_is_obj_skipped() { + if !have_python3() { + eprintln!("skipping python_runtime_is_obj_skipped: python3 not on PATH"); + return; + } + + let skipped = skipped_runtime_objects(); + assert!( + !skipped.is_empty(), + "expected at least the python executable to be skipped" + ); + + let clgctl = compile_clgctl(); + let raw = run_python(&clgctl); + let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) + .expect("parse python callgrind output"); + + assert!(!graph.nodes().is_empty(), "expected a non-empty node set"); + assert!(!graph.edges().is_empty(), "expected a non-empty edge set"); + + // The shim that fired START is captured, so instrumentation began exactly + // where the fixture asked, and it is wired into the graph (not dropped as + // an orphan root): the seeder reconstructed the native stack at the OFF->ON + // transition. + assert!( + graph.nodes().iter().any(|n| n.function == "clg_start"), + "clg_start (instrumentation shim) missing from graph" + ); + assert!( + graph + .edges() + .iter() + .any(|e| e.caller.function == "clg_start" || e.callee.function == "clg_start"), + "clg_start has no edges - START frame was not captured" + ); + + // The runtime obj-skip folded the Python runtime out: no node belongs to + // libpython or the python executable, and the interpreter loop is gone. + for obj in &skipped { + assert!( + graph.nodes().iter().all(|n| &n.object != obj), + "obj-skip failed: {obj} still present as a node object" + ); + } + assert!( + !graph + .nodes() + .iter() + .any(|n| n.function.starts_with("_PyEval_EvalFrameDefault")), + "interpreter loop _PyEval_EvalFrameDefault should have been obj-skipped" + ); +} From 695a1e99fd4fbfedc41477ff958f81c0af3f5468 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 18:31:43 +0200 Subject: [PATCH 06/10] chore: dont ignore libc/ld --- callgrind-utils/tests/snapshot.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/callgrind-utils/tests/snapshot.rs b/callgrind-utils/tests/snapshot.rs index 85dd7710d..26f196818 100644 --- a/callgrind-utils/tests/snapshot.rs +++ b/callgrind-utils/tests/snapshot.rs @@ -71,8 +71,6 @@ fn run_callgrind(bin: &Path) -> String { let status = Command::new(vg_in_place()) .arg("--tool=callgrind") .arg("--instr-atstart=no") - .arg("--obj-skip=*libc*") - .arg("--obj-skip=*ld-*") .arg(format!("--callgrind-out-file={}", out_file.display())) .arg(bin) .status() From facc042bbd0a27675337cf862b82a6554cbef50a Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 18:34:45 +0200 Subject: [PATCH 07/10] fixup: inst-at-start=yes tests --- callgrind-utils/tests/snapshot.rs | 108 +----------------- .../snapshots/snapshot__chain_full__json.snap | 85 ++++++++++++++ .../snapshot__diamond_full__json.snap | 100 ++++++++++++++++ .../snapshot__mutual_full__json.snap | 100 ++++++++++++++++ .../snapshot__recursion_full__json.snap | 100 ++++++++++++++++ 5 files changed, 387 insertions(+), 106 deletions(-) create mode 100644 callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap create mode 100644 callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap create mode 100644 callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap create mode 100644 callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap diff --git a/callgrind-utils/tests/snapshot.rs b/callgrind-utils/tests/snapshot.rs index 26f196818..d1d97e2bb 100644 --- a/callgrind-utils/tests/snapshot.rs +++ b/callgrind-utils/tests/snapshot.rs @@ -120,35 +120,6 @@ fn run_callgrind_full(bin: &Path) -> String { .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) } -/// Strip Callgrind's recursion-separation suffix (`fib'2` -> `fib`) so a -/// function and its deeper-recursion clones compare equal. -fn base(name: &str) -> &str { - name.split('\'').next().unwrap_or(name) -} - -/// Sum the `call_count` over every edge with these exact caller/callee -/// function names (recursion clones are distinct names, so pass them exactly). -fn sum_counts(g: &CallGraph, caller: &str, callee: &str) -> u64 { - g.edges() - .iter() - .filter(|e| e.caller.function == caller && e.callee.function == callee) - .map(|e| e.call_count.unwrap_or(0)) - .sum() -} - -/// True if any edge connects these two functions, regardless of count. -fn has_edge(g: &CallGraph, caller: &str, callee: &str) -> bool { - g.edges() - .iter() - .any(|e| e.caller.function == caller && e.callee.function == callee) -} - -/// Full-program trace matrix: profile each fixture with `--instr-atstart=yes` -/// and assert version-stable invariants rather than a golden snapshot (the -/// startup frames carry libc/loader-specific names like -/// `__libc_start_main@@GLIBC_2.34` and raw loader addresses, which are not -/// portable). This is both an end-to-end test of full-program tracing and a -/// regression guard for the arm64 `B`-vs-`BL` jump-kind fix. #[rstest] #[case("recursion")] #[case("chain")] @@ -159,82 +130,7 @@ fn fixture_full_trace(#[case] stem: &str) { let raw = run_callgrind_full(&bin); let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) .unwrap_or_else(|e| panic!("parse {stem} full callgrind output: {e:?}")); + let json = graph.to_json().expect("to_json"); - // The canonical JSON projection round-trips on a large, real-world graph. - graph.to_json().expect("to_json"); - - // Full-program capture: `main` is reached as a callee. The scoped run never - // observes this (instrumentation starts inside `main`). - assert!( - graph.edges().iter().any(|e| e.callee.function == "main"), - "{stem}: expected `main` to appear as a callee under --instr-atstart=yes" - ); - - // The fixture's own source functions all appear in the binary's object - // (which also carries CRT glue like `_start`/`frame_dummy` — hence subset, - // not equality). This is the same self-contained sub-graph as the scoped - // snapshot, now surrounded by — but not merged with — the startup frames. - let own: Vec<&str> = graph - .nodes() - .iter() - .filter(|n| n.object == stem) - .map(|n| n.function.as_str()) - .collect(); - let expected: &[&str] = match stem { - "recursion" => &["compute", "fib", "fib'2", "main", "square"], - "chain" => &["a", "b", "c", "main"], - "diamond" => &["bottom", "left", "main", "right", "top"], - "mutual" => &["is_even", "is_even'2", "is_odd", "is_odd'2", "main"], - _ => unreachable!("unknown fixture {stem}"), - }; - for f in expected { - assert!( - own.contains(f), - "{stem}: expected fixture function `{f}` in object `{stem}`; got {own:?}" - ); - } - - // Per-fixture call-graph shape (matches the committed scoped snapshots; the - // ZERO_STATS-bounded compute region carries identical counts). - match stem { - "recursion" => { - // Direct recursion: fib(8) makes 2*fib(9)-1 = 67 fib invocations. - // compute->fib=1, fib->fib'2=2, leaving fib'2->fib'2=64. The pre-fix - // arm64 bug inflated this to 98 via phantom base-case "calls". - assert_eq!(sum_counts(&graph, "fib", "fib'2"), 2, "recursion fib->fib'2"); - assert_eq!( - sum_counts(&graph, "fib'2", "fib'2"), - 64, - "recursion fib'2->fib'2" - ); - } - "chain" => { - assert_eq!(sum_counts(&graph, "main", "a"), 1, "chain main->a"); - assert_eq!(sum_counts(&graph, "a", "b"), 1, "chain a->b"); - assert_eq!(sum_counts(&graph, "b", "c"), 1, "chain b->c"); - } - "diamond" => { - assert!(has_edge(&graph, "top", "left"), "diamond top->left"); - assert!(has_edge(&graph, "top", "right"), "diamond top->right"); - assert!(has_edge(&graph, "left", "bottom"), "diamond left->bottom"); - assert!(has_edge(&graph, "right", "bottom"), "diamond right->bottom"); - } - "mutual" => { - // is_even/is_odd are *mutually* recursive: neither ever calls itself. - // The arm64 B-vs-BL bug invented self-edges (is_even->is_even'2 etc.); - // guard against their return. - for e in graph.edges() { - let (cb, kb) = (base(&e.caller.function), base(&e.callee.function)); - if cb == "is_even" || cb == "is_odd" { - assert_ne!(cb, kb, "mutual: spurious self-edge {e:?}"); - } - } - assert!(has_edge(&graph, "is_even", "is_odd"), "mutual is_even->is_odd"); - assert!( - has_edge(&graph, "is_odd", "is_even'2"), - "mutual is_odd->is_even'2" - ); - } - _ => unreachable!("unknown fixture {stem}"), - } + insta::assert_snapshot!(format!("{stem}_full__json"), json); } diff --git a/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap new file mode 100644 index 000000000..7d074465e --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap @@ -0,0 +1,85 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "(below main)", + "file": "???", + "object": "chain" + }, + { + "function": "a", + "file": "chain.c", + "object": "chain" + }, + { + "function": "b", + "file": "chain.c", + "object": "chain" + }, + { + "function": "c", + "file": "chain.c", + "object": "chain" + }, + { + "function": "main", + "file": "chain.c", + "object": "chain" + }, + { + "function": "0x000000000001fd40", + "file": "???", + "object": "ld-linux-x86-64.so.2" + }, + { + "function": "(below main)", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "???", + "object": "libc.so.6" + } + ], + "edges": [ + { + "caller": 0, + "callee": 7, + "call_count": 0 + }, + { + "caller": 1, + "callee": 2, + "call_count": 1 + }, + { + "caller": 2, + "callee": 3, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 5, + "callee": 0, + "call_count": 0 + }, + { + "caller": 6, + "callee": 4, + "call_count": 0 + }, + { + "caller": 7, + "callee": 6, + "call_count": 0 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap new file mode 100644 index 000000000..36ac1e1a7 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap @@ -0,0 +1,100 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "(below main)", + "file": "???", + "object": "diamond" + }, + { + "function": "bottom", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "left", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "main", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "right", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "top", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "0x000000000001fd40", + "file": "???", + "object": "ld-linux-x86-64.so.2" + }, + { + "function": "(below main)", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "???", + "object": "libc.so.6" + } + ], + "edges": [ + { + "caller": 0, + "callee": 8, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 1 + }, + { + "caller": 3, + "callee": 5, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 5, + "callee": 2, + "call_count": 1 + }, + { + "caller": 5, + "callee": 4, + "call_count": 1 + }, + { + "caller": 6, + "callee": 0, + "call_count": 0 + }, + { + "caller": 7, + "callee": 3, + "call_count": 0 + }, + { + "caller": 8, + "callee": 7, + "call_count": 0 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap new file mode 100644 index 000000000..c283c42c1 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap @@ -0,0 +1,100 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "0x000000000001fd40", + "file": "???", + "object": "ld-linux-x86-64.so.2" + }, + { + "function": "(below main)", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "???", + "object": "mutual" + }, + { + "function": "is_even", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_even'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "main", + "file": "mutual.c", + "object": "mutual" + } + ], + "edges": [ + { + "caller": 0, + "callee": 3, + "call_count": 0 + }, + { + "caller": 1, + "callee": 8, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 0 + }, + { + "caller": 3, + "callee": 2, + "call_count": 0 + }, + { + "caller": 4, + "callee": 6, + "call_count": 1 + }, + { + "caller": 5, + "callee": 7, + "call_count": 2 + }, + { + "caller": 6, + "callee": 5, + "call_count": 1 + }, + { + "caller": 7, + "callee": 5, + "call_count": 2 + }, + { + "caller": 8, + "callee": 4, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap new file mode 100644 index 000000000..bdf11199b --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap @@ -0,0 +1,100 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "0x000000000001fd40", + "file": "???", + "object": "ld-linux-x86-64.so.2" + }, + { + "function": "(below main)", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "???", + "object": "recursion" + }, + { + "function": "compute", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib'2", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "main", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "square", + "file": "recursion.c", + "object": "recursion" + } + ], + "edges": [ + { + "caller": 0, + "callee": 3, + "call_count": 0 + }, + { + "caller": 1, + "callee": 7, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 0 + }, + { + "caller": 3, + "callee": 2, + "call_count": 0 + }, + { + "caller": 4, + "callee": 5, + "call_count": 1 + }, + { + "caller": 4, + "callee": 8, + "call_count": 1 + }, + { + "caller": 5, + "callee": 6, + "call_count": 2 + }, + { + "caller": 6, + "callee": 6, + "call_count": 64 + }, + { + "caller": 7, + "callee": 4, + "call_count": 1 + } + ] +} From a85aee48d20797b9ec249d190707642a0cfed680 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 16:41:29 +0000 Subject: [PATCH 08/10] chore: add aarch snapshots --- .../snapshot__chain_full__json.snap.new | 106 +++++++++++++++ .../snapshot__diamond_full__json.snap.new | 121 ++++++++++++++++++ .../snapshot__mutual_full__json.snap.new | 121 ++++++++++++++++++ .../snapshot__recursion_full__json.snap.new | 121 ++++++++++++++++++ 4 files changed, 469 insertions(+) create mode 100644 callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new create mode 100644 callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new create mode 100644 callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new create mode 100644 callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new diff --git a/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new new file mode 100644 index 000000000..442d272b7 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new @@ -0,0 +1,106 @@ +--- +source: tests/snapshot.rs +assertion_line: 135 +expression: json +--- +{ + "nodes": [ + { + "function": "(below main)", + "file": "???", + "object": "chain" + }, + { + "function": "a", + "file": "chain.c", + "object": "chain" + }, + { + "function": "b", + "file": "chain.c", + "object": "chain" + }, + { + "function": "c", + "file": "chain.c", + "object": "chain" + }, + { + "function": "main", + "file": "chain.c", + "object": "chain" + }, + { + "function": "0x0000000000017c40", + "file": "???", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_init", + "file": "dl-init.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_start", + "file": "rtld.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "libc-start.c", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "libc_start_call_main.h", + "object": "libc.so.6" + } + ], + "edges": [ + { + "caller": 0, + "callee": 8, + "call_count": 0 + }, + { + "caller": 1, + "callee": 2, + "call_count": 1 + }, + { + "caller": 2, + "callee": 3, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 5, + "callee": 7, + "call_count": 0 + }, + { + "caller": 6, + "callee": 0, + "call_count": 0 + }, + { + "caller": 7, + "callee": 6, + "call_count": 0 + }, + { + "caller": 8, + "callee": 9, + "call_count": 0 + }, + { + "caller": 9, + "callee": 4, + "call_count": 0 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new new file mode 100644 index 000000000..37bcf6a72 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new @@ -0,0 +1,121 @@ +--- +source: tests/snapshot.rs +assertion_line: 135 +expression: json +--- +{ + "nodes": [ + { + "function": "(below main)", + "file": "???", + "object": "diamond" + }, + { + "function": "bottom", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "left", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "main", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "right", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "top", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "0x0000000000017c40", + "file": "???", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_init", + "file": "dl-init.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_start", + "file": "rtld.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "libc-start.c", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "libc_start_call_main.h", + "object": "libc.so.6" + } + ], + "edges": [ + { + "caller": 0, + "callee": 9, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 1 + }, + { + "caller": 3, + "callee": 5, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 5, + "callee": 2, + "call_count": 1 + }, + { + "caller": 5, + "callee": 4, + "call_count": 1 + }, + { + "caller": 6, + "callee": 8, + "call_count": 0 + }, + { + "caller": 7, + "callee": 0, + "call_count": 0 + }, + { + "caller": 8, + "callee": 7, + "call_count": 0 + }, + { + "caller": 9, + "callee": 10, + "call_count": 0 + }, + { + "caller": 10, + "callee": 3, + "call_count": 0 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new new file mode 100644 index 000000000..4776982d2 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new @@ -0,0 +1,121 @@ +--- +source: tests/snapshot.rs +assertion_line: 135 +expression: json +--- +{ + "nodes": [ + { + "function": "0x0000000000017c40", + "file": "???", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_init", + "file": "dl-init.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_start", + "file": "rtld.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "libc-start.c", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "libc_start_call_main.h", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "???", + "object": "mutual" + }, + { + "function": "is_even", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_even'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "main", + "file": "mutual.c", + "object": "mutual" + } + ], + "edges": [ + { + "caller": 0, + "callee": 2, + "call_count": 0 + }, + { + "caller": 1, + "callee": 5, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 0 + }, + { + "caller": 3, + "callee": 4, + "call_count": 0 + }, + { + "caller": 4, + "callee": 10, + "call_count": 0 + }, + { + "caller": 5, + "callee": 3, + "call_count": 0 + }, + { + "caller": 6, + "callee": 8, + "call_count": 1 + }, + { + "caller": 7, + "callee": 9, + "call_count": 2 + }, + { + "caller": 8, + "callee": 7, + "call_count": 1 + }, + { + "caller": 9, + "callee": 7, + "call_count": 2 + }, + { + "caller": 10, + "callee": 6, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new new file mode 100644 index 000000000..f3fbb5e8d --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new @@ -0,0 +1,121 @@ +--- +source: tests/snapshot.rs +assertion_line: 135 +expression: json +--- +{ + "nodes": [ + { + "function": "0x0000000000017c40", + "file": "???", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_init", + "file": "dl-init.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "_dl_start", + "file": "rtld.c", + "object": "ld-linux-aarch64.so.1" + }, + { + "function": "__libc_start_main@@GLIBC_2.34", + "file": "libc-start.c", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "libc_start_call_main.h", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "???", + "object": "recursion" + }, + { + "function": "compute", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib'2", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "main", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "square", + "file": "recursion.c", + "object": "recursion" + } + ], + "edges": [ + { + "caller": 0, + "callee": 2, + "call_count": 0 + }, + { + "caller": 1, + "callee": 5, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 0 + }, + { + "caller": 3, + "callee": 4, + "call_count": 0 + }, + { + "caller": 4, + "callee": 9, + "call_count": 0 + }, + { + "caller": 5, + "callee": 3, + "call_count": 0 + }, + { + "caller": 6, + "callee": 7, + "call_count": 1 + }, + { + "caller": 6, + "callee": 10, + "call_count": 1 + }, + { + "caller": 7, + "callee": 8, + "call_count": 2 + }, + { + "caller": 8, + "callee": 8, + "call_count": 64 + }, + { + "caller": 9, + "callee": 6, + "call_count": 1 + } + ] +} From 9bea3b441c0a497afebaf3ba74515332a31929a5 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 19:05:23 +0200 Subject: [PATCH 09/10] test: stabilize callgrind topology snapshots --- callgrind-utils/src/lib.rs | 1 + callgrind-utils/src/redact.rs | 132 +++++++++++ callgrind-utils/tests/python_callgraph.rs | 133 +++++------ callgrind-utils/tests/snapshot.rs | 6 +- ...allgraph__recursion_py__topology_json.snap | 213 ++++++++++++++++++ .../snapshots/snapshot__chain_full__json.snap | 15 +- .../snapshot__chain_full__json.snap.new | 106 --------- .../snapshot__diamond_full__json.snap | 15 +- .../snapshot__diamond_full__json.snap.new | 121 ---------- .../snapshot__mutual_full__json.snap | 41 ++-- .../snapshot__mutual_full__json.snap.new | 121 ---------- .../snapshot__recursion_full__json.snap | 43 ++-- .../snapshot__recursion_full__json.snap.new | 121 ---------- 13 files changed, 449 insertions(+), 619 deletions(-) create mode 100644 callgrind-utils/src/redact.rs create mode 100644 callgrind-utils/tests/snapshots/python_callgraph__recursion_py__topology_json.snap delete mode 100644 callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new delete mode 100644 callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new delete mode 100644 callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new delete mode 100644 callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new diff --git a/callgrind-utils/src/lib.rs b/callgrind-utils/src/lib.rs index 162b2d680..ae703bfd9 100644 --- a/callgrind-utils/src/lib.rs +++ b/callgrind-utils/src/lib.rs @@ -2,4 +2,5 @@ pub mod error; pub mod model; mod normalize; pub mod parser; +mod redact; pub mod serialize; diff --git a/callgrind-utils/src/redact.rs b/callgrind-utils/src/redact.rs new file mode 100644 index 000000000..59cae400c --- /dev/null +++ b/callgrind-utils/src/redact.rs @@ -0,0 +1,132 @@ +use super::model::{CallGraph, Node}; + +const UNKNOWN: &str = "???"; + +impl CallGraph { + /// Redact host-specific node identity and rebuild the canonical graph. + pub fn redact(self) -> CallGraph { + let CallGraph { nodes, edges } = self; + let mut nodes = nodes; + let mut edges = edges; + + for node in &mut nodes { + redact_node(node); + } + + for edge in &mut edges { + redact_node(&mut edge.caller); + redact_node(&mut edge.callee); + } + + CallGraph::from_parts(nodes, edges) + } +} + +fn redact_node(node: &mut Node) { + node.object = redact_object(&node.object); + + if is_runtime_object(&node.object) { + node.function = UNKNOWN.to_string(); + node.file = UNKNOWN.to_string(); + return; + } + + node.function = redact_function(&node.function); +} + +fn redact_function(function: &str) -> String { + let function = strip_symbol_version(function); + if is_hex_address(function) { + return "".to_string(); + } + function.to_string() +} + +fn strip_symbol_version(function: &str) -> &str { + for marker in ["@@", "@"] { + let Some(index) = function.find(marker) else { + continue; + }; + let version = &function[index + marker.len()..]; + if is_symbol_version(version) { + return &function[..index]; + } + } + function +} + +fn is_symbol_version(version: &str) -> bool { + let Some(first) = version.chars().next() else { + return false; + }; + (first.is_ascii_alphanumeric() || first == '_') + && version + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') +} + +fn is_hex_address(function: &str) -> bool { + let Some(hex) = function.strip_prefix("0x") else { + return false; + }; + !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn redact_object(object: &str) -> String { + if is_loader_soname(object) { + return "ld-linux".to_string(); + } + if let Some(module) = cpython_extension_module(object) { + return format!("{module}.cpython.so"); + } + if is_libffi_soname(object) { + return "libffi.so".to_string(); + } + object.to_string() +} + +fn is_runtime_object(object: &str) -> bool { + object == "ld-linux" || is_libc_soname(object) +} + +fn is_libc_soname(object: &str) -> bool { + let Some(version) = object.strip_prefix("libc.so.") else { + return false; + }; + !version.is_empty() && version.chars().all(|c| c.is_ascii_digit()) +} + +fn cpython_extension_module(object: &str) -> Option<&str> { + let (module, suffix) = object.split_once(".cpython-")?; + let abi = suffix.strip_suffix(".so")?; + if module.is_empty() || abi.is_empty() { + return None; + } + Some(module) +} + +fn is_libffi_soname(object: &str) -> bool { + let Some(version) = object.strip_prefix("libffi.so.") else { + return false; + }; + !version.is_empty() + && version.chars().all(|c| c.is_ascii_digit() || c == '.') + && version.chars().any(|c| c.is_ascii_digit()) +} +fn is_loader_soname(object: &str) -> bool { + let Some(rest) = object.strip_prefix("ld-") else { + return false; + }; + let Some(index) = rest.find(".so.") else { + return false; + }; + + let loader_name = &rest[..index]; + let soname_version = &rest[index + ".so.".len()..]; + !loader_name.is_empty() + && loader_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + && !soname_version.is_empty() + && soname_version.chars().all(|c| c.is_ascii_digit()) +} diff --git a/callgrind-utils/tests/python_callgraph.rs b/callgrind-utils/tests/python_callgraph.rs index dadb109c1..d4f970243 100644 --- a/callgrind-utils/tests/python_callgraph.rs +++ b/callgrind-utils/tests/python_callgraph.rs @@ -1,14 +1,18 @@ -//! Structural test over the Python fixture (`testdata/recursion.py`). +//! Topology-only snapshot of the Python fixture's call graph. //! -//! Unlike the C snapshot tests, a live Python run can't be a golden snapshot: -//! Callgrind sees the CPython interpreter's C frames (version- and -//! platform-specific), never the Python functions. The fixture drives Callgrind -//! the way pytest-codspeed does: at runtime it adds the Python runtime objects -//! (libpython + the python executable) to the obj-skip list via the -//! `CALLGRIND_ADD_OBJ_SKIP` client request, so the interpreter's own frames are -//! folded into their callers and don't obfuscate the graph. This test profiles -//! the fixture live and asserts structural properties: instrumentation started -//! at the shim, and the runtime obj-skip removed the interpreter. +//! Mirrors `snapshot.rs` for the C fixtures: profile `testdata/recursion.py` +//! live under the in-repo Callgrind, parse, and snapshot a topology-only +//! view (nodes + caller/callee indices, no `call_count`; see below). +//! +//! Callgrind records the CPython interpreter's C frames, not the Python +//! functions: the interpreter loop is obj-skipped at runtime via the `clgctl` +//! shim's `CALLGRIND_ADD_OBJ_SKIP`, so what remains is the ctypes/libffi/libc +//! C-residual around the `clg_start`/`clg_stop` shim. The graph shape +//! (nodes + caller/callee indices) is stable after `CallGraph::redact()`: libc/ld +//! debug-derived fields collapse to `???`, and CPython extension / libffi object +//! suffixes are normalized. `call_count` on the seed-reconstructed residual edges +//! drifts run-to-run (loader/PLT timing), so it is stripped and the snapshot is +//! topology-only. //! //! Requires a built `./vg-in-place` at the repo root and `cc`. Silently skips //! when `python3` is not on PATH (mirrors the `.vgtest` `prereq` guards). @@ -16,7 +20,7 @@ use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::Command; -use callgrind_utils::model::{CallGraph, ParseOptions}; +use callgrind_utils::model::{CallGraph, Node, ParseOptions}; /// Repo root: this crate lives at `/callgrind-utils`. fn repo_root() -> PathBuf { @@ -44,34 +48,6 @@ fn have_python3() -> bool { .unwrap_or(false) } -/// The basenames of the objects `recursion.py` adds to the obj-skip list: -/// libpython and the python executable, resolved exactly as the fixture does -/// (realpath, then basename, matching Callgrind's normalized object names). -fn skipped_runtime_objects() -> Vec { - let script = "\ -import os, sys, sysconfig -ld = sysconfig.get_config_var('LDLIBRARY') -libdir = sysconfig.get_config_var('LIBDIR') -cands = [os.path.join(libdir, ld) if ld and libdir else None, - os.path.join(sys.prefix, 'lib', ld) if ld else None] -lp = next((p for p in cands if p and os.path.exists(p)), None) -for p in (lp, sys.executable): - if p: - print(os.path.basename(os.path.realpath(p))) -"; - let out = Command::new("python3") - .arg("-c") - .arg(script) - .output() - .expect("run python3 to resolve runtime objects"); - assert!(out.status.success(), "python3 obj resolution failed"); - String::from_utf8(out.stdout) - .expect("utf8") - .lines() - .map(str::to_owned) - .collect() -} - /// Compile the Callgrind client-request shim the Python fixture loads via /// `ctypes`, as a shared library against the in-repo `callgrind.h`. fn compile_clgctl() -> PathBuf { @@ -116,59 +92,54 @@ fn run_python(clgctl: &Path) -> String { .status() .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); assert!(status.success(), "vg-in-place exited with {status}"); - std::fs::read_to_string(&out_file).unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) + std::fs::read_to_string(&out_file) + .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) +} + +/// Topology-only JSON view: `nodes` then `edges` by index, no `call_count`. +#[derive(serde::Serialize)] +struct TopologyEdge { + caller: usize, + callee: usize, +} + +#[derive(serde::Serialize)] +struct TopologyGraph<'a> { + nodes: &'a [Node], + edges: Vec, } #[test] -fn python_runtime_is_obj_skipped() { +fn python_topology_json() { if !have_python3() { - eprintln!("skipping python_runtime_is_obj_skipped: python3 not on PATH"); + eprintln!("skipping python_topology_json: python3 not on PATH"); return; } - let skipped = skipped_runtime_objects(); - assert!( - !skipped.is_empty(), - "expected at least the python executable to be skipped" - ); - let clgctl = compile_clgctl(); let raw = run_python(&clgctl); let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) - .expect("parse python callgrind output"); - - assert!(!graph.nodes().is_empty(), "expected a non-empty node set"); - assert!(!graph.edges().is_empty(), "expected a non-empty edge set"); - - // The shim that fired START is captured, so instrumentation began exactly - // where the fixture asked, and it is wired into the graph (not dropped as - // an orphan root): the seeder reconstructed the native stack at the OFF->ON - // transition. - assert!( - graph.nodes().iter().any(|n| n.function == "clg_start"), - "clg_start (instrumentation shim) missing from graph" - ); - assert!( - graph + .unwrap_or_else(|e| panic!("parse python callgrind output: {e:?}")) + .redact(); + let nodes = graph.nodes(); + let topology = TopologyGraph { + nodes, + edges: graph .edges() .iter() - .any(|e| e.caller.function == "clg_start" || e.callee.function == "clg_start"), - "clg_start has no edges - START frame was not captured" - ); + .map(|e| TopologyEdge { + caller: nodes + .iter() + .position(|x| x == &e.caller) + .expect("caller node present"), + callee: nodes + .iter() + .position(|x| x == &e.callee) + .expect("callee node present"), + }) + .collect(), + }; + let json = serde_json::to_string_pretty(&topology).expect("serialize"); - // The runtime obj-skip folded the Python runtime out: no node belongs to - // libpython or the python executable, and the interpreter loop is gone. - for obj in &skipped { - assert!( - graph.nodes().iter().all(|n| &n.object != obj), - "obj-skip failed: {obj} still present as a node object" - ); - } - assert!( - !graph - .nodes() - .iter() - .any(|n| n.function.starts_with("_PyEval_EvalFrameDefault")), - "interpreter loop _PyEval_EvalFrameDefault should have been obj-skipped" - ); + insta::assert_snapshot!("recursion_py__topology_json", json); } diff --git a/callgrind-utils/tests/snapshot.rs b/callgrind-utils/tests/snapshot.rs index d1d97e2bb..ed9370d9c 100644 --- a/callgrind-utils/tests/snapshot.rs +++ b/callgrind-utils/tests/snapshot.rs @@ -89,7 +89,8 @@ fn fixture_canonical_json(#[case] stem: &str) { let bin = compile_fixture(stem); let raw = run_callgrind(&bin); let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) - .unwrap_or_else(|e| panic!("parse {stem} callgrind output: {e:?}")); + .unwrap_or_else(|e| panic!("parse {stem} callgrind output: {e:?}")) + .redact(); let json = graph.to_json().expect("to_json"); insta::assert_snapshot!(format!("{stem}__json"), json); @@ -129,7 +130,8 @@ fn fixture_full_trace(#[case] stem: &str) { let bin = compile_fixture(stem); let raw = run_callgrind_full(&bin); let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) - .unwrap_or_else(|e| panic!("parse {stem} full callgrind output: {e:?}")); + .unwrap_or_else(|e| panic!("parse {stem} full callgrind output: {e:?}")) + .redact(); let json = graph.to_json().expect("to_json"); insta::assert_snapshot!(format!("{stem}_full__json"), json); diff --git a/callgrind-utils/tests/snapshots/python_callgraph__recursion_py__topology_json.snap b/callgrind-utils/tests/snapshots/python_callgraph__recursion_py__topology_json.snap new file mode 100644 index 000000000..4c2ecb67a --- /dev/null +++ b/callgrind-utils/tests/snapshots/python_callgraph__recursion_py__topology_json.snap @@ -0,0 +1,213 @@ +--- +source: tests/python_callgraph.rs +expression: json +--- +{ + "nodes": [ + { + "function": "KeepRef", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "PyCFuncPtr_call'2", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "PyCFuncPtr_new", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_ctypes_callproc'2", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_ctypes_get_fielddesc", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_get_name", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_validate_paramflags", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "i_get", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "???", + "file": "???", + "object": "ld-linux" + }, + { + "function": "???", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "clg_start", + "file": "clgctl.c", + "object": "libclgctl.so" + }, + { + "function": "ffi_call", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call'2", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_int", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_int'2", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_unix64", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_unix64'2", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_prep_cif", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_prep_cif_machdep", + "file": "???", + "object": "libffi.so" + } + ], + "edges": [ + { + "caller": 0, + "callee": 8 + }, + { + "caller": 0, + "callee": 9 + }, + { + "caller": 1, + "callee": 3 + }, + { + "caller": 2, + "callee": 0 + }, + { + "caller": 2, + "callee": 5 + }, + { + "caller": 2, + "callee": 6 + }, + { + "caller": 2, + "callee": 8 + }, + { + "caller": 2, + "callee": 9 + }, + { + "caller": 3, + "callee": 8 + }, + { + "caller": 3, + "callee": 9 + }, + { + "caller": 3, + "callee": 12 + }, + { + "caller": 3, + "callee": 17 + }, + { + "caller": 8, + "callee": 8 + }, + { + "caller": 8, + "callee": 9 + }, + { + "caller": 9, + "callee": 1 + }, + { + "caller": 9, + "callee": 2 + }, + { + "caller": 9, + "callee": 8 + }, + { + "caller": 9, + "callee": 9 + }, + { + "caller": 10, + "callee": 15 + }, + { + "caller": 11, + "callee": 4 + }, + { + "caller": 11, + "callee": 7 + }, + { + "caller": 11, + "callee": 8 + }, + { + "caller": 11, + "callee": 9 + }, + { + "caller": 12, + "callee": 14 + }, + { + "caller": 14, + "callee": 16 + }, + { + "caller": 15, + "callee": 13 + }, + { + "caller": 17, + "callee": 18 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap index 7d074465e..bbf052b7c 100644 --- a/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap +++ b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap @@ -30,17 +30,12 @@ expression: json "object": "chain" }, { - "function": "0x000000000001fd40", + "function": "???", "file": "???", - "object": "ld-linux-x86-64.so.2" + "object": "ld-linux" }, { - "function": "(below main)", - "file": "???", - "object": "libc.so.6" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", + "function": "???", "file": "???", "object": "libc.so.6" } @@ -48,7 +43,7 @@ expression: json "edges": [ { "caller": 0, - "callee": 7, + "callee": 6, "call_count": 0 }, { @@ -77,7 +72,7 @@ expression: json "call_count": 0 }, { - "caller": 7, + "caller": 6, "callee": 6, "call_count": 0 } diff --git a/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new deleted file mode 100644 index 442d272b7..000000000 --- a/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap.new +++ /dev/null @@ -1,106 +0,0 @@ ---- -source: tests/snapshot.rs -assertion_line: 135 -expression: json ---- -{ - "nodes": [ - { - "function": "(below main)", - "file": "???", - "object": "chain" - }, - { - "function": "a", - "file": "chain.c", - "object": "chain" - }, - { - "function": "b", - "file": "chain.c", - "object": "chain" - }, - { - "function": "c", - "file": "chain.c", - "object": "chain" - }, - { - "function": "main", - "file": "chain.c", - "object": "chain" - }, - { - "function": "0x0000000000017c40", - "file": "???", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_init", - "file": "dl-init.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_start", - "file": "rtld.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", - "file": "libc-start.c", - "object": "libc.so.6" - }, - { - "function": "(below main)", - "file": "libc_start_call_main.h", - "object": "libc.so.6" - } - ], - "edges": [ - { - "caller": 0, - "callee": 8, - "call_count": 0 - }, - { - "caller": 1, - "callee": 2, - "call_count": 1 - }, - { - "caller": 2, - "callee": 3, - "call_count": 1 - }, - { - "caller": 4, - "callee": 1, - "call_count": 1 - }, - { - "caller": 5, - "callee": 7, - "call_count": 0 - }, - { - "caller": 6, - "callee": 0, - "call_count": 0 - }, - { - "caller": 7, - "callee": 6, - "call_count": 0 - }, - { - "caller": 8, - "callee": 9, - "call_count": 0 - }, - { - "caller": 9, - "callee": 4, - "call_count": 0 - } - ] -} diff --git a/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap index 36ac1e1a7..6f7d63089 100644 --- a/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap +++ b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap @@ -35,17 +35,12 @@ expression: json "object": "diamond" }, { - "function": "0x000000000001fd40", + "function": "???", "file": "???", - "object": "ld-linux-x86-64.so.2" + "object": "ld-linux" }, { - "function": "(below main)", - "file": "???", - "object": "libc.so.6" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", + "function": "???", "file": "???", "object": "libc.so.6" } @@ -53,7 +48,7 @@ expression: json "edges": [ { "caller": 0, - "callee": 8, + "callee": 7, "call_count": 0 }, { @@ -92,7 +87,7 @@ expression: json "call_count": 0 }, { - "caller": 8, + "caller": 7, "callee": 7, "call_count": 0 } diff --git a/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new deleted file mode 100644 index 37bcf6a72..000000000 --- a/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap.new +++ /dev/null @@ -1,121 +0,0 @@ ---- -source: tests/snapshot.rs -assertion_line: 135 -expression: json ---- -{ - "nodes": [ - { - "function": "(below main)", - "file": "???", - "object": "diamond" - }, - { - "function": "bottom", - "file": "diamond.c", - "object": "diamond" - }, - { - "function": "left", - "file": "diamond.c", - "object": "diamond" - }, - { - "function": "main", - "file": "diamond.c", - "object": "diamond" - }, - { - "function": "right", - "file": "diamond.c", - "object": "diamond" - }, - { - "function": "top", - "file": "diamond.c", - "object": "diamond" - }, - { - "function": "0x0000000000017c40", - "file": "???", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_init", - "file": "dl-init.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_start", - "file": "rtld.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", - "file": "libc-start.c", - "object": "libc.so.6" - }, - { - "function": "(below main)", - "file": "libc_start_call_main.h", - "object": "libc.so.6" - } - ], - "edges": [ - { - "caller": 0, - "callee": 9, - "call_count": 0 - }, - { - "caller": 2, - "callee": 1, - "call_count": 1 - }, - { - "caller": 3, - "callee": 5, - "call_count": 1 - }, - { - "caller": 4, - "callee": 1, - "call_count": 1 - }, - { - "caller": 5, - "callee": 2, - "call_count": 1 - }, - { - "caller": 5, - "callee": 4, - "call_count": 1 - }, - { - "caller": 6, - "callee": 8, - "call_count": 0 - }, - { - "caller": 7, - "callee": 0, - "call_count": 0 - }, - { - "caller": 8, - "callee": 7, - "call_count": 0 - }, - { - "caller": 9, - "callee": 10, - "call_count": 0 - }, - { - "caller": 10, - "callee": 3, - "call_count": 0 - } - ] -} diff --git a/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap index c283c42c1..240f6abbf 100644 --- a/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap +++ b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap @@ -5,17 +5,12 @@ expression: json { "nodes": [ { - "function": "0x000000000001fd40", + "function": "???", "file": "???", - "object": "ld-linux-x86-64.so.2" + "object": "ld-linux" }, { - "function": "(below main)", - "file": "???", - "object": "libc.so.6" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", + "function": "???", "file": "???", "object": "libc.so.6" }, @@ -53,12 +48,17 @@ expression: json "edges": [ { "caller": 0, - "callee": 3, + "callee": 2, "call_count": 0 }, { "caller": 1, - "callee": 8, + "callee": 1, + "call_count": 0 + }, + { + "caller": 1, + "callee": 7, "call_count": 0 }, { @@ -68,32 +68,27 @@ expression: json }, { "caller": 3, - "callee": 2, - "call_count": 0 + "callee": 5, + "call_count": 1 }, { "caller": 4, "callee": 6, - "call_count": 1 - }, - { - "caller": 5, - "callee": 7, "call_count": 2 }, { - "caller": 6, - "callee": 5, + "caller": 5, + "callee": 4, "call_count": 1 }, { - "caller": 7, - "callee": 5, + "caller": 6, + "callee": 4, "call_count": 2 }, { - "caller": 8, - "callee": 4, + "caller": 7, + "callee": 3, "call_count": 1 } ] diff --git a/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new deleted file mode 100644 index 4776982d2..000000000 --- a/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap.new +++ /dev/null @@ -1,121 +0,0 @@ ---- -source: tests/snapshot.rs -assertion_line: 135 -expression: json ---- -{ - "nodes": [ - { - "function": "0x0000000000017c40", - "file": "???", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_init", - "file": "dl-init.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_start", - "file": "rtld.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", - "file": "libc-start.c", - "object": "libc.so.6" - }, - { - "function": "(below main)", - "file": "libc_start_call_main.h", - "object": "libc.so.6" - }, - { - "function": "(below main)", - "file": "???", - "object": "mutual" - }, - { - "function": "is_even", - "file": "mutual.c", - "object": "mutual" - }, - { - "function": "is_even'2", - "file": "mutual.c", - "object": "mutual" - }, - { - "function": "is_odd", - "file": "mutual.c", - "object": "mutual" - }, - { - "function": "is_odd'2", - "file": "mutual.c", - "object": "mutual" - }, - { - "function": "main", - "file": "mutual.c", - "object": "mutual" - } - ], - "edges": [ - { - "caller": 0, - "callee": 2, - "call_count": 0 - }, - { - "caller": 1, - "callee": 5, - "call_count": 0 - }, - { - "caller": 2, - "callee": 1, - "call_count": 0 - }, - { - "caller": 3, - "callee": 4, - "call_count": 0 - }, - { - "caller": 4, - "callee": 10, - "call_count": 0 - }, - { - "caller": 5, - "callee": 3, - "call_count": 0 - }, - { - "caller": 6, - "callee": 8, - "call_count": 1 - }, - { - "caller": 7, - "callee": 9, - "call_count": 2 - }, - { - "caller": 8, - "callee": 7, - "call_count": 1 - }, - { - "caller": 9, - "callee": 7, - "call_count": 2 - }, - { - "caller": 10, - "callee": 6, - "call_count": 1 - } - ] -} diff --git a/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap index bdf11199b..7cd365d4d 100644 --- a/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap +++ b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap @@ -5,17 +5,12 @@ expression: json { "nodes": [ { - "function": "0x000000000001fd40", + "function": "???", "file": "???", - "object": "ld-linux-x86-64.so.2" + "object": "ld-linux" }, { - "function": "(below main)", - "file": "???", - "object": "libc.so.6" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", + "function": "???", "file": "???", "object": "libc.so.6" }, @@ -53,47 +48,47 @@ expression: json "edges": [ { "caller": 0, - "callee": 3, + "callee": 2, "call_count": 0 }, { "caller": 1, - "callee": 7, + "callee": 1, "call_count": 0 }, { - "caller": 2, - "callee": 1, + "caller": 1, + "callee": 6, "call_count": 0 }, { - "caller": 3, - "callee": 2, + "caller": 2, + "callee": 1, "call_count": 0 }, { - "caller": 4, - "callee": 5, + "caller": 3, + "callee": 4, "call_count": 1 }, { - "caller": 4, - "callee": 8, + "caller": 3, + "callee": 7, "call_count": 1 }, { - "caller": 5, - "callee": 6, + "caller": 4, + "callee": 5, "call_count": 2 }, { - "caller": 6, - "callee": 6, + "caller": 5, + "callee": 5, "call_count": 64 }, { - "caller": 7, - "callee": 4, + "caller": 6, + "callee": 3, "call_count": 1 } ] diff --git a/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new deleted file mode 100644 index f3fbb5e8d..000000000 --- a/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap.new +++ /dev/null @@ -1,121 +0,0 @@ ---- -source: tests/snapshot.rs -assertion_line: 135 -expression: json ---- -{ - "nodes": [ - { - "function": "0x0000000000017c40", - "file": "???", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_init", - "file": "dl-init.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "_dl_start", - "file": "rtld.c", - "object": "ld-linux-aarch64.so.1" - }, - { - "function": "__libc_start_main@@GLIBC_2.34", - "file": "libc-start.c", - "object": "libc.so.6" - }, - { - "function": "(below main)", - "file": "libc_start_call_main.h", - "object": "libc.so.6" - }, - { - "function": "(below main)", - "file": "???", - "object": "recursion" - }, - { - "function": "compute", - "file": "recursion.c", - "object": "recursion" - }, - { - "function": "fib", - "file": "recursion.c", - "object": "recursion" - }, - { - "function": "fib'2", - "file": "recursion.c", - "object": "recursion" - }, - { - "function": "main", - "file": "recursion.c", - "object": "recursion" - }, - { - "function": "square", - "file": "recursion.c", - "object": "recursion" - } - ], - "edges": [ - { - "caller": 0, - "callee": 2, - "call_count": 0 - }, - { - "caller": 1, - "callee": 5, - "call_count": 0 - }, - { - "caller": 2, - "callee": 1, - "call_count": 0 - }, - { - "caller": 3, - "callee": 4, - "call_count": 0 - }, - { - "caller": 4, - "callee": 9, - "call_count": 0 - }, - { - "caller": 5, - "callee": 3, - "call_count": 0 - }, - { - "caller": 6, - "callee": 7, - "call_count": 1 - }, - { - "caller": 6, - "callee": 10, - "call_count": 1 - }, - { - "caller": 7, - "callee": 8, - "call_count": 2 - }, - { - "caller": 8, - "callee": 8, - "call_count": 64 - }, - { - "caller": 9, - "callee": 6, - "call_count": 1 - } - ] -} From bdc4911938a1a4a6b6ae7720c73c8271fae193cc Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 30 Jun 2026 19:35:37 +0000 Subject: [PATCH 10/10] Fix ARM64 callgrind stack unwinding --- callgrind/bbcc.c | 32 ++++++++++++++++++-------------- callgrind/main.c | 19 +++++++++++++++++-- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/callgrind/bbcc.c b/callgrind/bbcc.c index 9b08923d3..72754a1c0 100644 --- a/callgrind/bbcc.c +++ b/callgrind/bbcc.c @@ -653,34 +653,38 @@ void CLG_(setup_bbcc)(BB* bb) /* Manipulate JmpKind if needed, only using BB specific info */ csp = CLG_(current_call_stack).sp; - /* A return not matching the top call in our callstack is a jump */ if ( (jmpkind == jk_Return) && (csp >0)) { Int csp_up = csp-1; call_entry* top_ce = &(CLG_(current_call_stack).entry[csp_up]); - /* We have a real return if - * - the stack pointer (SP) left the current stack frame, or - * - SP has the same value as when reaching the current function - * and the address of this BB is the return address of last call - * (we even allow to leave multiple frames if the SP stays the - * same and we find a matching return address) - * The latter condition is needed because on PPC, SP can stay - * the same over CALL=b(c)l / RET=b(c)lr boundaries + /* We have a real return if the stack pointer (SP) left the + * current stack frame. If the return target matches an older + * shadow-stack frame whose entry SP is also outside the current + * native frame, unwind all intervening frames. This happens in + * hand-written startup/loader code that can collapse multiple native + * frames before returning to a saved link register. + * + * If SP is unchanged, require the return target to match the recorded + * return address. This is needed because on PPC, SP can stay the same + * over CALL=b(c)l / RET=b(c)lr boundaries. */ - if (sp < top_ce->sp) popcount_on_return = 0; - else if (top_ce->sp == sp) { + if (sp < top_ce->sp) { + popcount_on_return = 0; + } + else { + Bool sp_changed = top_ce->sp != sp; while(1) { if (top_ce->ret_addr == bb_addr(bb)) break; if (csp_up>0) { csp_up--; top_ce = &(CLG_(current_call_stack).entry[csp_up]); - if (top_ce->sp == sp) { + if (top_ce->sp <= sp) { popcount_on_return++; - continue; + continue; } } - popcount_on_return = 0; + popcount_on_return = sp_changed ? 1 : 0; break; } } diff --git a/callgrind/main.c b/callgrind/main.c index b0f910e67..6394ee0fd 100644 --- a/callgrind/main.c +++ b/callgrind/main.c @@ -1471,8 +1471,17 @@ static void zero_state_cost(thread_info* t) { CLG_(zero_cost)( CLG_(sets).full, CLG_(current_state).cost ); + CLG_(zero_cost)( CLG_(sets).full, t->lastdump_cost ); } +static +void sync_lastdump_cost(thread_info* t) +{ + CLG_(copy_cost)( CLG_(sets).full, t->lastdump_cost, + CLG_(current_state).cost ); +} + + void CLG_(set_instrument_state)(const HChar* reason, Bool state) { if (CLG_(instrument_state) == state) { @@ -1486,9 +1495,15 @@ void CLG_(set_instrument_state)(const HChar* reason, Bool state) VG_(discard_translations_safely)( (Addr)0x1000, ~(SizeT)0xfff, "callgrind"); - /* reset internal state: call stacks, simulator */ + /* Reset internal state: call stacks, simulator. Switching collection off + * leaves already materialized BBCC/JCC costs for the final dump, but the + * live event counter no longer tracks a complete interval after collection + * stops inside a BB. Mark the live counter as already summarized so readers + * use the dumped totals instead of a stale/partial header summary. The next + * ON transition starts a fresh interval by clearing both current and + * last-dump counters. */ CLG_(forall_threads)(unwind_thread); - CLG_(forall_threads)(zero_state_cost); + CLG_(forall_threads)(state ? zero_state_cost : sync_lastdump_cost); (*CLG_(cachesim).clear)(); if (VG_(clo_verbosity) > 1)