Skip to main content

aatxe_bench/
lib.rs

1//! # aatxe-bench
2//!
3//! Authoring API + JSON emitter for aatxe-compatible Rust microbenchmarks.
4//!
5//! ## Quick start
6//!
7//! ```ignore
8//! use aatxe_bench::{bench, Suite};
9//!
10//! fn main() {
11//!     let mut suite = Suite::new("my-service");
12//!     bench(&mut suite, "parse_phone", || {
13//!         let _ = parse_phone("+34 612 345 678");
14//!     });
15//!     suite.emit_stdout();
16//! }
17//! ```
18//!
19//! Running the binary that calls `suite.emit_stdout()` prints a fully-formed
20//! [`aatxe_core::types::RunReport`] JSON, which `aatxe run --lang rust`
21//! ingests directly.
22//!
23//! ## Why a builder, not `#[bench]`?
24//!
25//! The stable Rust toolchain does not ship `#[bench]`. Criterion is the
26//! standard alternative but introduces a heavy dependency tree and a
27//! HTML-report-centric flow. Aatxe-bench instead exposes a small builder
28//! that integrates cleanly with `cargo run --release` — predictable,
29//! statically-typed, and trivial to embed in CI.
30//!
31//! The sampling loop mirrors the JS authoring API:
32//! * warmup iterations excluded from the measurement;
33//! * adaptive sampling that stops once the CV drops below
34//!   [`Options::target_cv`] *or* the time budget expires *or*
35//!   [`Options::max_iterations`] is hit;
36//! * automatic batch sizing for sub-µs operations.
37
38use aatxe_core::stats::summarize_samples;
39use aatxe_core::types::{BenchRun, Language, RunReport, SCHEMA_VERSION};
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::time::{Duration, Instant};
42
43/// Re-exported so bench authors can `use aatxe_bench::black_box;` without an
44/// extra `std::hint` import. Wrapping a value with `black_box` prevents LLVM
45/// from constant-folding or DCE-ing the benched expression.
46pub use std::hint::black_box;
47
48/// Defeat dead-code elimination on a benched expression. Returns `v`
49/// unchanged so it can be chained inline:
50///
51/// ```ignore
52/// bench(&mut suite, "parse", || { keep(parse_phone("x")); });
53/// ```
54///
55/// Equivalent to [`black_box`] but named for cross-SDK parity with the TS
56/// (`keep`) and Go (`Keep`) authoring APIs. Use whichever reads better.
57#[inline(always)]
58pub fn keep<T>(v: T) -> T {
59    black_box(v)
60}
61
62/// Per-bench tuning knobs. Defaults match the JS / aatxe-core defaults so a
63/// service can swap between languages without changing its CI gate.
64#[derive(Debug, Clone, Copy)]
65pub struct Options {
66    pub warmup: u32,
67    pub min_iterations: u32,
68    pub max_iterations: u32,
69    pub time_budget: Duration,
70    pub target_cv: f64,
71    pub batch_size: BatchSize,
72}
73
74#[derive(Debug, Clone, Copy)]
75pub enum BatchSize {
76    Auto,
77    Fixed(u32),
78}
79
80impl Default for Options {
81    fn default() -> Self {
82        Self {
83            warmup: 5,
84            min_iterations: 30,
85            max_iterations: 200,
86            time_budget: Duration::from_millis(1_000),
87            target_cv: 0.02,
88            batch_size: BatchSize::Auto,
89        }
90    }
91}
92
93/// Collection of benches that will be emitted as a single [`RunReport`].
94pub struct Suite {
95    service: String,
96    r#ref: String,
97    runner: String,
98    runs: Vec<BenchRun>,
99    started_at: String,
100}
101
102impl Suite {
103    pub fn new(service: impl Into<String>) -> Self {
104        warn_if_debug_build_once();
105        let service = std::env::var("AATXE_SERVICE")
106            .ok()
107            .unwrap_or_else(|| service.into());
108        let r#ref = std::env::var("AATXE_REF")
109            .ok()
110            .unwrap_or_else(|| "HEAD".to_string());
111        Self {
112            service,
113            r#ref,
114            runner: format!("aatxe-bench/{}", env!("CARGO_PKG_VERSION")),
115            runs: Vec::new(),
116            started_at: now_iso(),
117        }
118    }
119
120    /// Run `fn_` under the bench harness and accumulate the result.
121    pub fn run<F: FnMut()>(&mut self, name: &str, opts: Options, file: &str, mut fn_: F) {
122        let (samples, batch_size, elapsed_ns) = run_loop(opts, &mut fn_);
123        let s = summarize_samples(&samples);
124        self.runs.push(BenchRun {
125            name: name.to_string(),
126            file: file.to_string(),
127            iterations: samples.len() as u32,
128            batch_size,
129            elapsed_ns,
130            samples: samples.clone(),
131            mean: s.mean,
132            median: s.median,
133            trimmed_mean: s.trimmed_mean,
134            stddev: s.stddev,
135            cv: s.cv,
136            mad: s.mad,
137            iqr: s.iqr,
138            min: s.min,
139            max: s.max,
140            p50: s.p50,
141            p95: s.p95,
142            p99: s.p99,
143            metrics: Vec::new(),
144            tags: Vec::new(),
145        });
146    }
147
148    /// Finalise and emit the report as a JSON [`RunReport`] on stdout.
149    /// Use this from your runner's `main()` so `aatxe run --lang rust` can
150    /// ingest the output.
151    pub fn emit_stdout(self) {
152        let report = self.into_report();
153        println!("{}", serde_json::to_string_pretty(&report).expect("json"));
154    }
155
156    pub fn into_report(self) -> RunReport {
157        RunReport {
158            schema_version: SCHEMA_VERSION,
159            language: Language::Rust,
160            service: self.service,
161            r#ref: self.r#ref,
162            runner: self.runner,
163            started_at: self.started_at,
164            finished_at: now_iso(),
165            runs: self.runs,
166            affected_scope: None,
167        }
168    }
169}
170
171/// Convenience wrapper that uses default [`Options`] and a synthetic file tag.
172pub fn bench<F: FnMut()>(suite: &mut Suite, name: &str, fn_: F) {
173    suite.run(name, Options::default(), "<inline>", fn_);
174}
175
176/// Sampling loop. Returns the per-iteration durations (in ns), the resolved
177/// batch size, and the total measured wall-time (warmup excluded).
178fn run_loop<F: FnMut()>(opts: Options, fn_: &mut F) -> (Vec<f64>, u32, f64) {
179    // Resolve batch size: 'auto' calibrates so each timer reading takes ~50µs.
180    let batch_size = match opts.batch_size {
181        BatchSize::Fixed(n) => n.max(1),
182        BatchSize::Auto => calibrate_batch_size(fn_),
183    };
184
185    // Warmup — discarded.
186    for _ in 0..opts.warmup {
187        run_batch(fn_, batch_size);
188    }
189
190    let mut samples: Vec<f64> = Vec::with_capacity(opts.max_iterations as usize);
191    let total_start = Instant::now();
192    let mut elapsed_ns = 0.0_f64;
193    for i in 0..opts.max_iterations {
194        let t0 = Instant::now();
195        run_batch(fn_, batch_size);
196        let batch_ns = t0.elapsed().as_nanos() as f64;
197        samples.push(batch_ns / batch_size as f64);
198        elapsed_ns += batch_ns;
199        if i + 1 >= opts.min_iterations {
200            let cv = aatxe_core::stats::coefficient_of_variation(&samples);
201            let budget_done = total_start.elapsed() >= opts.time_budget;
202            let cv_done = opts.target_cv > 0.0 && cv > 0.0 && cv <= opts.target_cv;
203            if cv_done || budget_done {
204                break;
205            }
206        }
207    }
208    (samples, batch_size, elapsed_ns)
209}
210
211static DEBUG_BUILD_WARNED: AtomicBool = AtomicBool::new(false);
212
213/// Print a one-time stderr warning when the bench harness is invoked in a
214/// build with `debug_assertions` enabled (i.e. `cargo run` without `--release`).
215/// Debug builds are typically 5-50x slower and produce uncomparable numbers.
216fn warn_if_debug_build_once() {
217    if !cfg!(debug_assertions) {
218        return;
219    }
220    if DEBUG_BUILD_WARNED.swap(true, Ordering::Relaxed) {
221        return;
222    }
223    eprintln!(
224        "aatxe-bench: WARNING — running in a debug build (debug_assertions=on). \
225         Numbers will not be comparable to release builds. Re-run with `--release`."
226    );
227}
228
229#[inline(always)]
230fn run_batch<F: FnMut()>(fn_: &mut F, batch_size: u32) {
231    for _ in 0..batch_size {
232        fn_();
233    }
234}
235
236/// Pick a batch size so each sample takes ~50µs. Amortises the ~100ns
237/// `Instant::now()` call overhead for sub-µs benches.
238fn calibrate_batch_size<F: FnMut()>(fn_: &mut F) -> u32 {
239    let mut n: u32 = 1;
240    loop {
241        let t0 = Instant::now();
242        run_batch(fn_, n);
243        let dt = t0.elapsed();
244        if dt >= Duration::from_micros(50) || n >= 1_048_576 {
245            return n;
246        }
247        n = n.saturating_mul(2);
248    }
249}
250
251fn now_iso() -> String {
252    let t = time::OffsetDateTime::now_utc();
253    t.format(&time::format_description::well_known::Rfc3339)
254        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn bench_records_samples() {
263        let mut s = Suite::new("test-svc");
264        bench(&mut s, "noop", || {
265            // Cheap fn — auto-batched.
266            std::hint::black_box(1 + 1);
267        });
268        let r = s.into_report();
269        assert_eq!(r.language, Language::Rust);
270        assert_eq!(r.runs.len(), 1);
271        let run = &r.runs[0];
272        assert!(run.iterations >= 30, "min_iterations should be respected");
273        assert!(run.median.is_finite() && run.median >= 0.0);
274        assert!(run.batch_size >= 1);
275    }
276
277    #[test]
278    fn calibrate_returns_at_least_one() {
279        let mut f = || {
280            std::hint::black_box(2 * 3);
281        };
282        let n = calibrate_batch_size(&mut f);
283        assert!(n >= 1);
284    }
285
286    #[test]
287    fn fixed_batch_size_is_honoured() {
288        let mut s = Suite::new("test-svc");
289        let opts = Options {
290            batch_size: BatchSize::Fixed(64),
291            // Tiny budgets so the test finishes fast.
292            min_iterations: 5,
293            max_iterations: 5,
294            warmup: 0,
295            time_budget: Duration::from_millis(10),
296            target_cv: 0.0,
297        };
298        s.run("fixed", opts, "<inline>", || {
299            std::hint::black_box(1 + 1);
300        });
301        let r = s.into_report();
302        assert_eq!(r.runs[0].batch_size, 64);
303        assert_eq!(r.runs[0].iterations, 5);
304    }
305
306    #[test]
307    fn multiple_benches_accumulate_into_one_report() {
308        let mut s = Suite::new("multi");
309        bench(&mut s, "a", || {
310            std::hint::black_box(1);
311        });
312        bench(&mut s, "b", || {
313            std::hint::black_box(2);
314        });
315        bench(&mut s, "c", || {
316            std::hint::black_box(3);
317        });
318        let r = s.into_report();
319        assert_eq!(r.runs.len(), 3);
320        let names: Vec<&str> = r.runs.iter().map(|x| x.name.as_str()).collect();
321        assert_eq!(names, vec!["a", "b", "c"]);
322    }
323
324    #[test]
325    fn into_report_carries_schema_version_and_language() {
326        let s = Suite::new("test-svc");
327        let r = s.into_report();
328        assert_eq!(r.schema_version, aatxe_core::types::SCHEMA_VERSION);
329        assert_eq!(r.language, Language::Rust);
330        assert!(
331            r.runner.starts_with("aatxe-bench/"),
332            "runner string should self-identify, got {:?}",
333            r.runner
334        );
335    }
336
337    #[test]
338    fn env_overrides_service_and_ref() {
339        std::env::set_var("AATXE_SERVICE", "from-env");
340        std::env::set_var("AATXE_REF", "deadbeef");
341        let s = Suite::new("ignored");
342        let r = s.into_report();
343        // Cleanup before asserting so a failure can't leak state.
344        std::env::remove_var("AATXE_SERVICE");
345        std::env::remove_var("AATXE_REF");
346        assert_eq!(r.service, "from-env");
347        assert_eq!(r.r#ref, "deadbeef");
348    }
349
350    #[test]
351    fn elapsed_ns_equals_sum_of_per_sample_times_batch() {
352        // Regression: a previous version called Instant::elapsed() twice per
353        // iteration, causing elapsed_ns to drift above the true measured time.
354        // The invariant we restore: elapsed_ns ≈ sum(samples) * batch_size,
355        // exactly equal modulo f64 rounding.
356        let mut s = Suite::new("svc");
357        let opts = Options {
358            batch_size: BatchSize::Fixed(8),
359            min_iterations: 10,
360            max_iterations: 10,
361            warmup: 0,
362            time_budget: Duration::from_secs(60),
363            target_cv: 0.0,
364        };
365        s.run("invariant", opts, "<inline>", || {
366            std::hint::black_box(1u64.wrapping_mul(7));
367        });
368        let r = s.into_report();
369        let run = &r.runs[0];
370        let reconstructed: f64 = run.samples.iter().sum::<f64>() * run.batch_size as f64;
371        let delta = (run.elapsed_ns - reconstructed).abs();
372        assert!(
373            delta <= 1.0,
374            "elapsed_ns ({}) drifted from sum(samples)*batch_size ({}) by {}ns",
375            run.elapsed_ns,
376            reconstructed,
377            delta
378        );
379    }
380
381    #[test]
382    fn keep_and_black_box_are_re_exported() {
383        // Compile-time check that the ergonomic re-exports exist and apply.
384        let v = keep(7u64);
385        let w = black_box(v.wrapping_add(1));
386        assert_eq!(w, 8);
387    }
388
389    #[test]
390    fn target_cv_short_circuits_when_distribution_is_tight() {
391        // Constant-time fn → CV converges to ~0 quickly; with a generous
392        // max_iterations and tight target_cv, we should stop well before max.
393        // We do a small amount of real work (500 adds) so measurement noise
394        // is small relative to the signal, keeping CV stable.
395        let mut s = Suite::new("svc");
396        let opts = Options {
397            batch_size: BatchSize::Fixed(512),
398            min_iterations: 30,
399            max_iterations: 200,
400            warmup: 2,
401            time_budget: Duration::from_secs(60),
402            target_cv: 1.0, // generous — should trip fast on a tight distribution
403        };
404        s.run("tight", opts, "<inline>", || {
405            let mut x = 0u64;
406            for _ in 0..500 {
407                x = x.wrapping_add(std::hint::black_box(1));
408            }
409            std::hint::black_box(x);
410        });
411        let r = s.into_report();
412        assert!(
413            r.runs[0].iterations < 200,
414            "expected early stop, got {} iterations",
415            r.runs[0].iterations
416        );
417    }
418}