Skip to content

Commit 5ad1e61

Browse files
committed
refactor: wrap external callgrind graph
1 parent 9274fe4 commit 5ad1e61

25 files changed

Lines changed: 7155 additions & 1809 deletions

callgrind-utils/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

callgrind-utils/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ edition = "2024"
66
[dependencies]
77
inferno = { version = "0.12.6", default-features = false }
88
callgrind-parser = { git = "ssh://git@github.com/CodSpeedHQ/platform.git", package = "callgrind-parser" }
9+
callgraph-shared = { git = "ssh://git@github.com/CodSpeedHQ/platform.git", package = "callgraph-shared" }
10+
petgraph = "0.7.1"
911
serde = { version = "1", features = ["derive"] }
1012
serde_json = "1"
1113
thiserror = "2"

callgrind-utils/src/flamegraph.rs

Lines changed: 58 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,61 @@
1+
use std::collections::HashMap;
2+
3+
use callgraph_shared::interfaces::CostVec;
14
use inferno::flamegraph::{self, Options};
5+
use petgraph::{
6+
graph::NodeIndex,
7+
visit::{EdgeRef, IntoEdgeReferences},
8+
};
29

310
use super::{error::FlamegraphError, model::CallGraph};
411

5-
/// Below this incoming budget a frame rounds to zero cost, so descending
6-
/// further would only emit empty lines.
712
const MIN_BUDGET: f64 = 1.0;
8-
9-
/// Fraction of the total cost below which a branch is pruned. Budget is
10-
/// conserved and splits across a node's children, so a relative floor bounds
11-
/// the number of surviving paths to ~1/this, keeping the fold tractable on
12-
/// large graphs with heavily-shared subtrees (e.g. a real Node/V8 profile).
1313
const MIN_BUDGET_FRACTION: f64 = 0.0005;
1414

1515
impl CallGraph {
16-
/// Fold the graph into Brendan-Gregg "collapsed stack" lines
17-
/// (`root;child;leaf <count>`), the input format for flamegraph tools.
18-
///
19-
/// Each line carries a node's SELF cost for one call path; a frame's width
20-
/// in the rendered graph is the sum of the self costs of everything beneath
21-
/// it, i.e. its inclusive cost. Because Callgrind aggregates cost per
22-
/// function (not per call path), a node's self cost is distributed across
23-
/// its incoming paths in proportion to each call's inclusive cost.
2416
pub fn to_folded(&self) -> Vec<String> {
25-
let n = self.nodes.len();
26-
let names: Vec<&str> = self
27-
.nodes
17+
let graph = self.inner.graph.borrow();
18+
let indices: Vec<NodeIndex> = graph.node_indices().collect();
19+
let index_by_node: HashMap<NodeIndex, usize> = indices
20+
.iter()
21+
.enumerate()
22+
.map(|(index, node)| (*node, index))
23+
.collect();
24+
25+
let names: Vec<&str> = indices
26+
.iter()
27+
.map(|index| graph[*index].name.as_str())
28+
.collect();
29+
let self_costs: Vec<u64> = indices
2830
.iter()
29-
.map(|node| node.function.as_str())
31+
.map(|index| first_cost(&graph[*index].costs.aggregated_cost()))
3032
.collect();
3133

32-
// Adjacency + incoming inclusive cost, keyed by sorted-node index.
33-
let mut out: Vec<Vec<(usize, u64)>> = vec![Vec::new(); n];
34-
let mut incoming_incl: Vec<u64> = vec![0; n];
35-
for e in &self.edges {
36-
let (Some(c), Some(d)) = (self.node_index(&e.caller), self.node_index(&e.callee))
37-
else {
34+
let n = indices.len();
35+
let mut out = vec![Vec::<(usize, u64)>::new(); n];
36+
let mut incoming_incl = vec![0; n];
37+
for edge in graph.edge_references() {
38+
let Some(&caller) = index_by_node.get(&edge.source()) else {
3839
continue;
3940
};
40-
let ic = e.inclusive_cost.unwrap_or(0);
41-
out[c].push((d, ic));
42-
incoming_incl[d] += ic;
41+
let Some(&callee) = index_by_node.get(&edge.target()) else {
42+
continue;
43+
};
44+
let inclusive_cost = first_cost(&edge.weight().costs.aggregated_cost());
45+
out[caller].push((callee, inclusive_cost));
46+
incoming_incl[callee] += inclusive_cost;
4347
}
4448

45-
// incl[i] = self[i] + sum of outgoing inclusive costs; the total cost
46-
// attributed to node i across all call paths.
4749
let incl: Vec<u64> = (0..n)
48-
.map(|i| self.self_cost(i) + out[i].iter().map(|(_, c)| *c).sum::<u64>())
50+
.map(|i| self_costs[i] + out[i].iter().map(|(_, cost)| *cost).sum::<u64>())
4951
.collect();
5052

51-
let roots = self.roots(&incoming_incl, &incl);
52-
let total: f64 = roots.iter().map(|(_, b)| *b).sum();
53+
let roots = roots(&incoming_incl, &incl);
54+
let total: f64 = roots.iter().map(|(_, budget)| *budget).sum();
5355
let min_budget = MIN_BUDGET.max(total * MIN_BUDGET_FRACTION);
5456

5557
let mut lines = Vec::new();
56-
let mut stack: Vec<usize> = Vec::new();
58+
let mut stack = Vec::new();
5759
let mut on_path = vec![false; n];
5860
for (root, budget) in roots {
5961
fold_dfs(
@@ -64,15 +66,14 @@ impl CallGraph {
6466
&mut on_path,
6567
&out,
6668
&incl,
67-
&self.self_costs,
69+
&self_costs,
6870
&names,
6971
&mut lines,
7072
);
7173
}
7274
lines
7375
}
7476

75-
/// Render the graph to a flamegraph SVG string.
7677
pub fn to_flamegraph(&self) -> Result<String, FlamegraphError> {
7778
let lines = self.to_folded();
7879
if lines.is_empty() {
@@ -83,13 +84,12 @@ impl CallGraph {
8384
opts.title = "Callgrind".to_string();
8485
opts.count_name = "instructions".to_string();
8586

86-
let mut svg: Vec<u8> = Vec::new();
87+
let mut svg = Vec::new();
8788
flamegraph::from_lines(&mut opts, lines.iter().map(String::as_str), &mut svg)
8889
.map_err(|e| FlamegraphError::Inferno(e.to_string()))?;
8990
String::from_utf8(svg).map_err(|e| FlamegraphError::Inferno(e.to_string()))
9091
}
9192

92-
/// Render the graph to a flamegraph SVG file at `path`.
9393
pub fn to_flamegraph_file(
9494
&self,
9595
path: impl AsRef<std::path::Path>,
@@ -98,39 +98,30 @@ impl CallGraph {
9898
std::fs::write(path, svg)?;
9999
Ok(())
100100
}
101+
}
101102

102-
/// Entry points for the flame stacks, as `(node, budget)` pairs.
103-
///
104-
/// A node's `budget` is the part of its inclusive cost NOT accounted for by
105-
/// any recorded incoming call, i.e. `incl - sum(incoming edge inclusive)`.
106-
/// This is nonzero for true roots (no caller) and, crucially, for frames
107-
/// that were already on the stack when `--instr-atstart=no` instrumentation
108-
/// began: their entry call predates measurement, so no edge carries their
109-
/// cost and they would otherwise be dropped. A fully-cyclic graph has no
110-
/// such node, so fall back to the single costliest node.
111-
fn roots(&self, incoming_incl: &[u64], incl: &[u64]) -> Vec<(usize, f64)> {
112-
let roots: Vec<(usize, f64)> = (0..self.nodes.len())
113-
.filter_map(|i| {
114-
let uncovered = incl[i].saturating_sub(incoming_incl[i]);
115-
(uncovered > 0).then_some((i, uncovered as f64))
116-
})
117-
.collect();
118-
if !roots.is_empty() {
119-
return roots;
120-
}
121-
(0..self.nodes.len())
122-
.filter(|&i| incl[i] > 0)
123-
.max_by_key(|&i| incl[i])
124-
.map(|i| (i, incl[i] as f64))
125-
.into_iter()
126-
.collect()
103+
fn first_cost(cost: &CostVec) -> u64 {
104+
cost.0.first().copied().unwrap_or(0)
105+
}
106+
107+
fn roots(incoming_incl: &[u64], incl: &[u64]) -> Vec<(usize, f64)> {
108+
let roots: Vec<(usize, f64)> = (0..incl.len())
109+
.filter_map(|i| {
110+
let uncovered = incl[i].saturating_sub(incoming_incl[i]);
111+
(uncovered > 0).then_some((i, uncovered as f64))
112+
})
113+
.collect();
114+
if !roots.is_empty() {
115+
return roots;
127116
}
117+
(0..incl.len())
118+
.filter(|&i| incl[i] > 0)
119+
.max_by_key(|&i| incl[i])
120+
.map(|i| (i, incl[i] as f64))
121+
.into_iter()
122+
.collect()
128123
}
129124

130-
/// Emit one collapsed-stack line per node reachable from `node`, distributing
131-
/// self cost by `budget / incl` (the share of this node's total cost that flows
132-
/// through the current path). A node already on the path is a recursion edge:
133-
/// emit it as a leaf carrying the incoming budget rather than descend.
134125
#[allow(clippy::too_many_arguments)]
135126
fn fold_dfs(
136127
node: usize,
@@ -151,10 +142,6 @@ fn fold_dfs(
151142
stack.push(node);
152143
on_path[node] = true;
153144

154-
// Share of this node's aggregated cost that flows through the current path.
155-
// In consistent Callgrind data a single incoming edge never exceeds the
156-
// node's total inclusive cost, so frac <= 1; clamp guards against malformed
157-
// input amplifying cost down the tree.
158145
let frac = (budget / incl[node] as f64).min(1.0);
159146
let self_here = (self_costs[node] as f64 * frac).round() as u64;
160147
if self_here >= 1 {
@@ -178,7 +165,6 @@ fn fold_dfs(
178165
);
179166
continue;
180167
}
181-
// Recursion: represent the recursive cost without looping.
182168
let recursive = child_budget.round() as u64;
183169
if recursive >= 1 {
184170
stack.push(child);
@@ -191,7 +177,6 @@ fn fold_dfs(
191177
stack.pop();
192178
}
193179

194-
/// `name0;name1;...;nameK <count>` for the current index stack.
195180
fn fold_line(stack: &[usize], names: &[&str], count: u64) -> String {
196181
let mut line = String::new();
197182
for (i, &idx) in stack.iter().enumerate() {

callgrind-utils/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
pub mod error;
22
pub mod flamegraph;
33
pub mod model;
4-
mod normalize;
54
pub mod parser;
65
mod redact;
76
pub mod serialize;

0 commit comments

Comments
 (0)