1#![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
36pub struct Benchmark {
38 name: String,
39 samples: Vec<Duration>,
40}
41
42impl Benchmark {
43 pub fn new(name: impl Into<String>) -> Self {
45 Self {
46 name: name.into(),
47 samples: Vec::new(),
48 }
49 }
50
51 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 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#[derive(Debug, Clone)]
91pub struct BenchmarkResult {
92 pub name: String,
94 pub samples: Vec<Duration>,
96 pub mean: Duration,
98 pub p50: Duration,
100 pub p99: Duration,
102}
103
104#[derive(Debug, Clone, Copy)]
106pub enum Threshold {
107 RegressionPct(f64),
110 RegressionAbsoluteNs(u128),
113}
114
115impl Threshold {
116 pub fn regression_pct(pct: f64) -> Self {
118 Threshold::RegressionPct(pct)
119 }
120
121 pub fn regression_abs_ns(nanos: u128) -> Self {
123 Threshold::RegressionAbsoluteNs(nanos)
124 }
125}
126
127impl BenchmarkResult {
128 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
163pub trait Bench {
165 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}