Skip to main content

profile_inspect/analysis/
heap_analysis.rs

1use std::collections::HashMap;
2
3use crate::ir::{FrameCategory, FrameId, ProfileIR};
4
5/// Statistics for a single allocation site
6#[derive(Debug, Clone)]
7pub struct AllocationStats {
8    /// Frame ID
9    pub frame_id: FrameId,
10    /// Function name
11    pub name: String,
12    /// Source location
13    pub location: String,
14    /// Category
15    pub category: FrameCategory,
16    /// Self size (bytes allocated directly by this frame)
17    pub self_size: u64,
18    /// Total size (including callees)
19    pub total_size: u64,
20    /// Number of allocations
21    pub allocation_count: u32,
22}
23
24impl AllocationStats {
25    /// Calculate self size as percentage of total
26    #[expect(clippy::cast_precision_loss)]
27    pub fn self_percent(&self, total: u64) -> f64 {
28        if total == 0 {
29            0.0
30        } else {
31            (self.self_size as f64 / total as f64) * 100.0
32        }
33    }
34
35    /// Format size as human-readable string
36    pub fn format_size(bytes: u64) -> String {
37        const KB: u64 = 1024;
38        const MB: u64 = KB * 1024;
39        const GB: u64 = MB * 1024;
40
41        if bytes >= GB {
42            format!("{:.2} GB", bytes as f64 / GB as f64)
43        } else if bytes >= MB {
44            format!("{:.2} MB", bytes as f64 / MB as f64)
45        } else if bytes >= KB {
46            format!("{:.2} KB", bytes as f64 / KB as f64)
47        } else {
48            format!("{bytes} B")
49        }
50    }
51}
52
53/// Result of heap profile analysis
54#[derive(Debug)]
55pub struct HeapAnalysis {
56    /// Total allocated bytes
57    pub total_size: u64,
58    /// Total number of allocations
59    pub total_allocations: usize,
60    /// Per-function allocation statistics
61    pub functions: Vec<AllocationStats>,
62    /// Size breakdown by category
63    pub category_breakdown: CategorySizeBreakdown,
64}
65
66/// Size breakdown by category
67#[derive(Debug, Clone, Default)]
68pub struct CategorySizeBreakdown {
69    pub app: u64,
70    pub deps: u64,
71    pub node_internal: u64,
72    pub v8_internal: u64,
73    pub native: u64,
74}
75
76impl CategorySizeBreakdown {
77    /// Get total size
78    pub fn total(&self) -> u64 {
79        self.app + self.deps + self.node_internal + self.v8_internal + self.native
80    }
81}
82
83/// Analyzer for heap profiles
84pub struct HeapAnalyzer {
85    /// Minimum percentage to include in results
86    min_percent: f64,
87    /// Maximum number of functions to return
88    top_n: usize,
89    /// Whether to include internal frames
90    include_internals: bool,
91}
92
93impl HeapAnalyzer {
94    /// Create a new analyzer with default settings
95    pub fn new() -> Self {
96        Self {
97            min_percent: 0.0,
98            top_n: 50,
99            include_internals: false,
100        }
101    }
102
103    /// Set minimum percentage threshold
104    pub fn min_percent(mut self, percent: f64) -> Self {
105        self.min_percent = percent;
106        self
107    }
108
109    /// Set maximum number of results
110    pub fn top_n(mut self, n: usize) -> Self {
111        self.top_n = n;
112        self
113    }
114
115    /// Include internal frames
116    pub fn include_internals(mut self, include: bool) -> Self {
117        self.include_internals = include;
118        self
119    }
120
121    /// Analyze a heap profile
122    #[expect(clippy::cast_precision_loss)]
123    pub fn analyze(&self, profile: &ProfileIR) -> HeapAnalysis {
124        let total_size = profile.total_weight();
125        let total_allocations = profile.sample_count();
126
127        // Aggregate sizes per frame
128        let mut self_sizes: HashMap<FrameId, u64> = HashMap::new();
129        let mut total_sizes: HashMap<FrameId, u64> = HashMap::new();
130        let mut alloc_counts: HashMap<FrameId, u32> = HashMap::new();
131        let mut category_breakdown = CategorySizeBreakdown::default();
132
133        for sample in &profile.samples {
134            let size = sample.weight;
135
136            if let Some(stack) = profile.get_stack(sample.stack_id) {
137                // Leaf frame gets self size
138                if let Some(&leaf_frame) = stack.frames.last() {
139                    *self_sizes.entry(leaf_frame).or_default() += size;
140                    *alloc_counts.entry(leaf_frame).or_default() += 1;
141
142                    // Attribute to category based on leaf frame
143                    if let Some(frame) = profile.get_frame(leaf_frame) {
144                        match frame.category {
145                            FrameCategory::App => category_breakdown.app += size,
146                            FrameCategory::Deps => category_breakdown.deps += size,
147                            FrameCategory::NodeInternal => {
148                                category_breakdown.node_internal += size;
149                            }
150                            FrameCategory::V8Internal => category_breakdown.v8_internal += size,
151                            FrameCategory::Native => category_breakdown.native += size,
152                        }
153                    }
154                }
155
156                // All frames get total size
157                for &frame_id in &stack.frames {
158                    *total_sizes.entry(frame_id).or_default() += size;
159                }
160            }
161        }
162
163        // Build allocation stats
164        let mut functions: Vec<AllocationStats> = profile
165            .frames
166            .iter()
167            .filter_map(|frame| {
168                let self_size = self_sizes.get(&frame.id).copied().unwrap_or(0);
169                let total_size = total_sizes.get(&frame.id).copied().unwrap_or(0);
170
171                // Skip if no allocations
172                if self_size == 0 && total_size == 0 {
173                    return None;
174                }
175
176                // Apply internal filter
177                if !self.include_internals && frame.category.is_internal() {
178                    return None;
179                }
180
181                // Apply min percent filter
182                let self_pct = if total_size > 0 {
183                    (self_size as f64 / total_size as f64) * 100.0
184                } else {
185                    0.0
186                };
187                if self_pct < self.min_percent && self.min_percent > 0.0 {
188                    return None;
189                }
190
191                Some(AllocationStats {
192                    frame_id: frame.id,
193                    name: frame.display_name().to_string(),
194                    location: frame.location(),
195                    category: frame.category,
196                    self_size,
197                    total_size,
198                    allocation_count: alloc_counts.get(&frame.id).copied().unwrap_or(0),
199                })
200            })
201            .collect();
202
203        // Sort by self size descending
204        functions.sort_by(|a, b| b.self_size.cmp(&a.self_size));
205        functions.truncate(self.top_n);
206
207        HeapAnalysis {
208            total_size,
209            total_allocations,
210            functions,
211            category_breakdown,
212        }
213    }
214}
215
216impl Default for HeapAnalyzer {
217    fn default() -> Self {
218        Self::new()
219    }
220}