Skip to main content

dev_bench/
lib.rs

1//! # dev-bench
2//!
3//! Performance measurement and regression detection for Rust. Part of
4//! the `dev-*` verification suite.
5//!
6//! `dev-bench` answers the question: did this change make the code
7//! faster, slower, or stay the same? It compares current measurements
8//! against a stored baseline and emits verdicts via `dev-report`.
9//!
10//! ## Quick example
11//!
12//! ```no_run
13//! use dev_bench::{Benchmark, Threshold};
14//!
15//! let mut b = Benchmark::new("parse_query");
16//! for _ in 0..1000 {
17//!     b.iter(|| {
18//!         // code under measurement
19//!         std::hint::black_box(40 + 2);
20//!     });
21//! }
22//!
23//! let result = b.finish();
24//! let threshold = Threshold::regression_pct(10.0);   // fail on +10%
25//! let verdict = result.compare_against_baseline(None, threshold);
26//! ```
27
28#![cfg_attr(docsrs, feature(doc_cfg))]
29#![warn(missing_docs)]
30#![warn(rust_2018_idioms)]
31
32use std::time::{Duration, Instant};
33
34use dev_report::{CheckResult, Severity};
35
36/// A single benchmark run.
37pub struct Benchmark {
38    name: String,
39    samples: Vec<Duration>,
40}
41
42impl Benchmark {
43    /// Begin a new benchmark with a stable name.
44    pub fn new(name: impl Into<String>) -> Self {
45        Self {
46            name: name.into(),
47            samples: Vec::new(),
48        }
49    }
50
51    /// Run one iteration of the benchmark, capturing the duration.
52    pub fn iter<F, R>(&mut self, f: F) -> R
53    where
54        F: FnOnce() -> R,
55    {
56        let start = Instant::now();
57        let r = f();
58        let elapsed = start.elapsed();
59        self.samples.push(elapsed);
60        r
61    }
62
63    /// Finalize the benchmark and produce a result summary.
64    pub fn finish(self) -> BenchmarkResult {
65        let n = self.samples.len();
66        let mean = if n == 0 {
67            Duration::ZERO
68        } else {
69            let total: Duration = self.samples.iter().copied().sum();
70            total / n as u32
71        };
72        let mut sorted = self.samples.clone();
73        sorted.sort();
74        let p50 = sorted.get(n / 2).copied().unwrap_or(Duration::ZERO);
75        let p99 = sorted
76            .get((n as f64 * 0.99).floor() as usize)
77            .copied()
78            .unwrap_or(Duration::ZERO);
79        BenchmarkResult {
80            name: self.name,
81            samples: self.samples,
82            mean,
83            p50,
84            p99,
85        }
86    }
87}
88
89/// The result of a finished benchmark.
90#[derive(Debug, Clone)]
91pub struct BenchmarkResult {
92    /// Stable name of the benchmark.
93    pub name: String,
94    /// All raw sample durations.
95    pub samples: Vec<Duration>,
96    /// Mean sample duration.
97    pub mean: Duration,
98    /// 50th percentile sample duration.
99    pub p50: Duration,
100    /// 99th percentile sample duration.
101    pub p99: Duration,
102}
103
104/// A threshold defining how much slower-than-baseline is acceptable.
105#[derive(Debug, Clone, Copy)]
106pub enum Threshold {
107    /// Fail if the new measurement is more than `pct` percent slower
108    /// than the baseline.
109    RegressionPct(f64),
110    /// Fail if the new measurement is more than `nanos` slower than
111    /// the baseline.
112    RegressionAbsoluteNs(u128),
113}
114
115impl Threshold {
116    /// Build a percent-based regression threshold.
117    pub fn regression_pct(pct: f64) -> Self {
118        Threshold::RegressionPct(pct)
119    }
120
121    /// Build an absolute regression threshold in nanoseconds.
122    pub fn regression_abs_ns(nanos: u128) -> Self {
123        Threshold::RegressionAbsoluteNs(nanos)
124    }
125}
126
127impl BenchmarkResult {
128    /// Compare this result against a baseline mean. If `baseline_mean`
129    /// is `None`, the comparison is skipped and verdict is `Skip`.
130    pub fn compare_against_baseline(
131        &self,
132        baseline_mean: Option<Duration>,
133        threshold: Threshold,
134    ) -> CheckResult {
135        let Some(baseline) = baseline_mean else {
136            return CheckResult::skip(format!("bench::{}", self.name))
137                .with_detail("no baseline available");
138        };
139        let current_ns = self.mean.as_nanos();
140        let baseline_ns = baseline.as_nanos();
141        let regressed = match threshold {
142            Threshold::RegressionPct(pct) => {
143                let allowed = baseline_ns as f64 * (1.0 + pct / 100.0);
144                current_ns as f64 > allowed
145            }
146            Threshold::RegressionAbsoluteNs(abs) => {
147                current_ns.saturating_sub(baseline_ns) > abs
148            }
149        };
150        let detail = format!(
151            "current mean {} ns, baseline {} ns",
152            current_ns, baseline_ns
153        );
154        let name = format!("bench::{}", self.name);
155        if regressed {
156            CheckResult::fail(name, Severity::Warning).with_detail(detail)
157        } else {
158            CheckResult::pass(name).with_detail(detail)
159        }
160    }
161}
162
163/// A trait for any object that can run a benchmark and produce a result.
164pub trait Bench {
165    /// Run the benchmark and return its result.
166    fn run(&mut self) -> BenchmarkResult;
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use dev_report::Verdict;
173
174    #[test]
175    fn benchmark_runs_and_finishes() {
176        let mut b = Benchmark::new("noop");
177        for _ in 0..10 {
178            b.iter(|| std::hint::black_box(42));
179        }
180        let r = b.finish();
181        assert_eq!(r.samples.len(), 10);
182        assert!(r.mean > Duration::ZERO);
183    }
184
185    #[test]
186    fn comparison_without_baseline_is_skip() {
187        let mut b = Benchmark::new("x");
188        b.iter(|| ());
189        let r = b.finish();
190        let v = r.compare_against_baseline(None, Threshold::regression_pct(5.0));
191        assert_eq!(v.verdict, Verdict::Skip);
192    }
193
194    #[test]
195    fn small_regression_under_threshold_passes() {
196        let mut b = Benchmark::new("x");
197        for _ in 0..5 {
198            b.iter(|| std::thread::sleep(Duration::from_micros(1)));
199        }
200        let r = b.finish();
201        let baseline = r.mean;
202        let v = r.compare_against_baseline(Some(baseline), Threshold::regression_pct(50.0));
203        assert_eq!(v.verdict, Verdict::Pass);
204    }
205}