Skip to main content

forge/profile/analyze/
aggregate.rs

1use wowlab_types::sim::{FastMap, FastSet};
2
3use super::{
4    symbols::{clean_name, parse_module, pct, round1, round2, symbol_to_source},
5    types::{CallEdge, ContextEntry, Function, HotspotContext},
6};
7use crate::profile::{categories, samply::RawProfile};
8
9const MAX_CAT_FUNCS: usize = 10;
10const MIN_BOTTLENECK_PCT: f64 = 5.0;
11const MAX_BOTTLENECKS: usize = 15;
12const MIN_EDGE_PCT: f64 = 0.3;
13const MAX_CALL_EDGES: usize = 80;
14const MIN_CATEGORY_PCT: f64 = 0.1;
15const MAX_CONTEXT_ENTRIES: usize = 5;
16const MAX_HOTSPOT_CONTEXT: usize = 15;
17const BOTTLENECK_SELF_PCT_THRESHOLD: f64 = 1.0;
18const BOTTLENECK_RATIO_THRESHOLD: f64 = 3.0;
19const BOTTLENECK_RATIO_EPSILON: f64 = 0.01;
20
21pub(super) struct SampleCounts {
22    self_counts: FastMap<String, u64>,
23    total_counts: FastMap<String, u64>,
24    edge_counts: FastMap<(String, String), u64>,
25    call_sources: FastMap<String, FastMap<String, u64>>,
26    call_destinations: FastMap<String, FastMap<String, u64>>,
27}
28
29pub(super) struct FunctionListResult {
30    pub(super) all_functions: Vec<Function>,
31    pub(super) category_self: FastMap<String, f64>,
32    pub(super) cat_funcs: FastMap<String, Vec<Function>>,
33    pub(super) source_self: FastMap<String, f64>,
34}
35
36// #t(fn: rust_alloc_in_loop, rust_clone_in_loop) building owned keys for HashMap entry API
37pub(super) fn count_samples<'a>(
38    profile: &'a RawProfile,
39    sym: &dyn Fn(usize) -> &'a str,
40) -> SampleCounts {
41    let sample_capacity = profile.samples.len();
42    let mut self_counts = FastMap::default();
43    let mut total_counts = FastMap::default();
44    let mut edge_counts = FastMap::default();
45    let mut call_sources: FastMap<String, FastMap<String, u64>> = FastMap::default();
46    let mut call_destinations: FastMap<String, FastMap<String, u64>> = FastMap::default();
47
48    self_counts.reserve(sample_capacity);
49    total_counts.reserve(sample_capacity);
50    edge_counts.reserve(sample_capacity);
51    call_sources.reserve(sample_capacity);
52    call_destinations.reserve(sample_capacity);
53    let mut seen = FastSet::default();
54
55    for sample in &profile.samples {
56        if sample.frames.is_empty() {
57            continue;
58        }
59
60        let weight = sample.weight;
61
62        // BOUNDS: frames is non-empty, checked above
63        let leaf = sym(sample.frames[0]).to_string();
64
65        *self_counts.entry(leaf.clone()).or_default() += weight;
66
67        seen.clear();
68        let mut prev: Option<String> = None;
69
70        for &frame_idx in &sample.frames {
71            let func = sym(frame_idx).to_string();
72
73            if seen.insert(func.clone()) {
74                *total_counts.entry(func.clone()).or_default() += weight;
75            }
76
77            if let Some(ref p) = prev {
78                if p != &func {
79                    *edge_counts.entry((func.clone(), p.clone())).or_default() += weight;
80                    *call_sources
81                        .entry(p.clone())
82                        .or_default()
83                        .entry(func.clone())
84                        .or_default() += weight;
85                    *call_destinations
86                        .entry(func.clone())
87                        .or_default()
88                        .entry(p.clone())
89                        .or_default() += weight;
90                }
91            }
92
93            prev = Some(func);
94        }
95    }
96
97    SampleCounts {
98        self_counts,
99        total_counts,
100        edge_counts,
101        call_sources,
102        call_destinations,
103    }
104}
105
106// #t(fn: rust_alloc_in_loop, rust_clone_in_loop) building Function structs with owned strings
107pub(super) fn build_function_list(counts: &SampleCounts, total_weight: u64) -> FunctionListResult {
108    let mut all_functions = Vec::with_capacity(counts.self_counts.len());
109    let mut category_self = FastMap::default();
110    let mut cat_funcs: FastMap<String, Vec<Function>> = FastMap::default();
111    let mut source_self = FastMap::default();
112
113    let mut by_self: Vec<(&String, &u64)> = counts.self_counts.iter().collect();
114
115    by_self.sort_by(|a, b| b.1.cmp(a.1));
116
117    for &(name, self_cnt) in &by_self {
118        let self_cnt = *self_cnt;
119        let module = parse_module(name);
120        let category = categories::classify(name).to_string();
121        let self_p = pct(self_cnt, total_weight);
122        let total_p = pct(
123            *counts.total_counts.get(name.as_str()).unwrap_or(&0),
124            total_weight,
125        );
126
127        *category_self.entry(category.clone()).or_default() += self_p;
128
129        if let Some(src) = symbol_to_source(name) {
130            *source_self.entry(src).or_default() += self_p;
131        }
132
133        if category == "stdlib" {
134            continue;
135        }
136
137        let func = Function {
138            name: name.clone(),
139            module,
140            category: category.clone(),
141            self_pct: round2(self_p),
142            total_pct: round2(total_p),
143            self_samples: self_cnt,
144            total_samples: *counts.total_counts.get(name.as_str()).unwrap_or(&0),
145        };
146
147        let cat_list = cat_funcs.entry(category).or_default();
148
149        if cat_list.len() < MAX_CAT_FUNCS {
150            cat_list.push(Function {
151                name: clean_name(name),
152                ..func.clone()
153            });
154        }
155
156        all_functions.push(func);
157    }
158
159    FunctionListResult {
160        all_functions,
161        category_self,
162        cat_funcs,
163        source_self,
164    }
165}
166
167// #t(fn: rust_alloc_in_loop, rust_clone_in_loop) building owned bottleneck Function structs
168pub(super) fn find_bottlenecks(counts: &SampleCounts, total_weight: u64) -> Vec<Function> {
169    let mut bottlenecks = Vec::with_capacity(MAX_BOTTLENECKS);
170    let mut by_total: Vec<(&String, &u64)> = counts.total_counts.iter().collect();
171
172    by_total.sort_by(|a, b| b.1.cmp(a.1));
173
174    for &(name, total_cnt) in &by_total {
175        let total_cnt = *total_cnt;
176        let total_p = pct(total_cnt, total_weight);
177
178        if total_p < MIN_BOTTLENECK_PCT {
179            break;
180        }
181
182        let cat = categories::classify(name);
183
184        if cat == "stdlib" {
185            continue;
186        }
187
188        let self_p = pct(
189            *counts.self_counts.get(name.as_str()).unwrap_or(&0),
190            total_weight,
191        );
192
193        if self_p < BOTTLENECK_SELF_PCT_THRESHOLD
194            || total_p / self_p.max(BOTTLENECK_RATIO_EPSILON) >= BOTTLENECK_RATIO_THRESHOLD
195        {
196            bottlenecks.push(Function {
197                name: name.clone(),
198                module: parse_module(name),
199                category: cat.to_string(),
200                self_pct: round2(self_p),
201                total_pct: round2(total_p),
202                self_samples: *counts.self_counts.get(name.as_str()).unwrap_or(&0),
203                total_samples: total_cnt,
204            });
205        }
206
207        if bottlenecks.len() >= MAX_BOTTLENECKS {
208            break;
209        }
210    }
211
212    bottlenecks
213}
214
215// #t(fn: rust_clone_in_loop) edge entries need owned caller/callee strings
216pub(super) fn build_call_edges(
217    counts: &SampleCounts,
218    all_functions: &[Function],
219    top_n: usize,
220    total_weight: u64,
221) -> Vec<CallEdge> {
222    let top_names: FastSet<&str> = all_functions
223        .iter()
224        .take(top_n)
225        .map(|f| f.name.as_str())
226        .collect();
227
228    let mut call_edges = Vec::with_capacity(MAX_CALL_EDGES);
229    let mut edges_sorted: Vec<(&(String, String), &u64)> = counts.edge_counts.iter().collect();
230
231    edges_sorted.sort_by(|a, b| b.1.cmp(a.1));
232
233    for &(edge, cnt) in &edges_sorted {
234        let (source, destination) = edge;
235        let cnt = *cnt;
236
237        if top_names.contains(source.as_str()) || top_names.contains(destination.as_str()) {
238            let p = pct(cnt, total_weight);
239
240            if p >= MIN_EDGE_PCT {
241                call_edges.push(CallEdge {
242                    caller: source.clone(),
243                    callee: destination.clone(),
244                    samples: cnt,
245                    pct: round2(p),
246                });
247            }
248        }
249
250        if call_edges.len() >= MAX_CALL_EDGES {
251            break;
252        }
253    }
254
255    call_edges
256}
257
258pub(super) fn build_hotspot_context(
259    counts: &SampleCounts,
260    all_functions: &[Function],
261    total_weight: u64,
262) -> Vec<HotspotContext> {
263    all_functions
264        .iter()
265        .take(MAX_HOTSPOT_CONTEXT)
266        .map(|f| {
267            let incoming = top_entries(&counts.call_sources, &f.name, total_weight);
268            let outgoing = top_entries(&counts.call_destinations, &f.name, total_weight);
269
270            HotspotContext {
271                name: clean_name(&f.name),
272                self_pct: f.self_pct,
273                total_pct: f.total_pct,
274                category: f.category.clone(),
275                callers: incoming,
276                callees: outgoing,
277            }
278        })
279        .collect()
280}
281
282pub(super) fn build_categories(category_self: FastMap<String, f64>) -> Vec<(String, f64)> {
283    let mut categories: Vec<(String, f64)> = category_self
284        .into_iter()
285        .filter(|(_, p)| *p >= MIN_CATEGORY_PCT)
286        .map(|(k, v)| (k, round1(v)))
287        .collect();
288
289    categories.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
290
291    categories
292}
293
294fn top_entries(
295    map: &FastMap<String, FastMap<String, u64>>,
296    key: &str,
297    total_weight: u64,
298) -> Vec<ContextEntry> {
299    let Some(inner) = map.get(key) else {
300        return Vec::new();
301    };
302    let mut entries: Vec<(&String, &u64)> = inner.iter().collect();
303
304    entries.sort_by(|a, b| b.1.cmp(a.1));
305
306    entries
307        .into_iter()
308        .take(MAX_CONTEXT_ENTRIES)
309        .filter(|(_, cnt)| pct(**cnt, total_weight) >= MIN_EDGE_PCT)
310        .map(|(name, cnt)| {
311            let cnt = *cnt;
312
313            ContextEntry {
314                name: clean_name(name),
315                pct: round2(pct(cnt, total_weight)),
316                samples: cnt,
317            }
318        })
319        .collect()
320}