Skip to main content

dev_bench/
alloc.rs

1//! Allocation tracking. Available with the `alloc-tracking` feature.
2//!
3//! Wraps `mod-alloc`'s `dhat_compat` surface (a drop-in replacement
4//! for `dhat-rs`) to capture total bytes, total allocation count,
5//! and peak resident bytes during a benchmark. Reports a
6//! [`CheckResult`] with regression-style verdict.
7//!
8//! As of v0.9.7 the backend is `mod-alloc` instead of `dhat`; the
9//! public API surface here (`AllocationStats`, the
10//! `install_global_allocator!` macro) is unchanged. JSON output
11//! from `mod-alloc::dhat_compat::Profiler` loads in the same
12//! upstream `dh_view.html` viewer.
13//!
14//! ## Cost
15//!
16//! Enabling `alloc-tracking` installs a tracking global allocator.
17//! It is heavier than the default allocator and changes timing
18//! characteristics. **Do not combine allocation thresholds with
19//! timing thresholds in the same invocation.** Run timing
20//! benchmarks with the feature off and allocation benchmarks with
21//! it on.
22//!
23//! ## Setup
24//!
25//! Use the [`install_global_allocator!`](crate::install_global_allocator)
26//! macro at module scope in your binary or test target:
27//!
28//! ```ignore
29//! dev_bench::install_global_allocator!();
30//! ```
31//!
32//! Then start a profiler before the benchmark and snapshot stats after:
33//!
34//! ```ignore
35//! use mod_alloc::dhat_compat as dhat;
36//!
37//! let _profiler = dhat::Profiler::new_heap();
38//! // ... run benchmarked code ...
39//! let stats = dev_bench::alloc::AllocationStats::snapshot();
40//! ```
41//!
42//! The macro expands to a `#[global_allocator] static` of
43//! `mod_alloc::dhat_compat::Alloc`.
44
45use dev_report::{CheckResult, Evidence, Severity};
46
47/// Snapshot of allocation activity, captured from
48/// `mod_alloc::dhat_compat::HeapStats` (drop-in for
49/// `dhat::HeapStats`).
50///
51/// Build via [`AllocationStats::snapshot`] inside a
52/// `mod_alloc::dhat_compat::Profiler` scope.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct AllocationStats {
55    /// Total bytes allocated across the profiled scope (cumulative).
56    pub total_bytes: u64,
57    /// Total number of allocations across the profiled scope.
58    pub total_blocks: u64,
59    /// Peak bytes resident at any one time.
60    pub peak_bytes: u64,
61    /// Peak number of blocks resident at any one time.
62    pub peak_blocks: u64,
63}
64
65impl AllocationStats {
66    /// Capture the current `HeapStats` into an `AllocationStats`.
67    ///
68    /// SHOULD be called inside an active
69    /// `mod_alloc::dhat_compat::Profiler::new_heap()` scope so the
70    /// profiler's drop-time JSON write captures the same data. The
71    /// snapshot itself works outside a Profiler scope (returns
72    /// zeros if no global allocator is installed); the historical
73    /// `dhat-rs` panic is no longer present.
74    pub fn snapshot() -> Self {
75        let s = mod_alloc::dhat_compat::HeapStats::get();
76        Self {
77            total_bytes: s.total_bytes,
78            total_blocks: s.total_blocks,
79            peak_bytes: s.max_bytes as u64,
80            peak_blocks: s.max_blocks as u64,
81        }
82    }
83
84    /// Compare this snapshot against a baseline.
85    ///
86    /// `pct_threshold` is the maximum tolerated growth in
87    /// `total_bytes` over the baseline. A regression beyond the
88    /// threshold yields `Fail (Warning)`. No baseline yields `Skip`.
89    pub fn compare_against_baseline(
90        &self,
91        name: &str,
92        baseline: Option<AllocationStats>,
93        pct_threshold: f64,
94    ) -> CheckResult {
95        let check_name = format!("alloc::{}", name);
96        let mut evidence = vec![
97            Evidence::numeric("total_bytes", self.total_bytes as f64),
98            Evidence::numeric("total_blocks", self.total_blocks as f64),
99            Evidence::numeric("peak_bytes", self.peak_bytes as f64),
100            Evidence::numeric("peak_blocks", self.peak_blocks as f64),
101        ];
102
103        let Some(base) = baseline else {
104            let mut c = CheckResult::skip(check_name).with_detail("no allocation baseline");
105            c.tags = vec!["alloc".to_string()];
106            c.evidence = evidence;
107            return c;
108        };
109
110        evidence.push(Evidence::numeric(
111            "baseline_total_bytes",
112            base.total_bytes as f64,
113        ));
114        let allowed = base.total_bytes as f64 * (1.0 + pct_threshold / 100.0);
115        let regressed = (self.total_bytes as f64) > allowed;
116        let detail = format!(
117            "current_total_bytes={} baseline_total_bytes={} threshold_pct={}",
118            self.total_bytes, base.total_bytes, pct_threshold
119        );
120        if regressed {
121            let mut c = CheckResult::fail(check_name, Severity::Warning).with_detail(detail);
122            c.tags = vec!["alloc".to_string(), "regression".to_string()];
123            c.evidence = evidence;
124            c
125        } else {
126            let mut c = CheckResult::pass(check_name).with_detail(detail);
127            c.tags = vec!["alloc".to_string()];
128            c.evidence = evidence;
129            c
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use dev_report::Verdict;
138
139    fn synthetic(total_bytes: u64) -> AllocationStats {
140        AllocationStats {
141            total_bytes,
142            total_blocks: 10,
143            peak_bytes: total_bytes,
144            peak_blocks: 5,
145        }
146    }
147
148    #[test]
149    fn no_baseline_skips() {
150        let s = synthetic(1_000);
151        let c = s.compare_against_baseline("x", None, 10.0);
152        assert_eq!(c.verdict, Verdict::Skip);
153        assert!(c.has_tag("alloc"));
154    }
155
156    #[test]
157    fn within_threshold_passes() {
158        let curr = synthetic(105);
159        let base = synthetic(100);
160        let c = curr.compare_against_baseline("x", Some(base), 10.0);
161        assert_eq!(c.verdict, Verdict::Pass);
162    }
163
164    #[test]
165    fn over_threshold_fails() {
166        let curr = synthetic(120);
167        let base = synthetic(100);
168        let c = curr.compare_against_baseline("x", Some(base), 10.0);
169        assert_eq!(c.verdict, Verdict::Fail);
170        assert!(c.has_tag("regression"));
171    }
172
173    #[test]
174    fn evidence_includes_all_metrics() {
175        let curr = synthetic(100);
176        let c = curr.compare_against_baseline("x", None, 10.0);
177        let labels: Vec<&str> = c.evidence.iter().map(|e| e.label.as_str()).collect();
178        assert!(labels.contains(&"total_bytes"));
179        assert!(labels.contains(&"total_blocks"));
180        assert!(labels.contains(&"peak_bytes"));
181        assert!(labels.contains(&"peak_blocks"));
182    }
183}