1use 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
43pub use std::hint::black_box;
47
48#[inline(always)]
58pub fn keep<T>(v: T) -> T {
59 black_box(v)
60}
61
62#[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
93pub 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 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 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
171pub fn bench<F: FnMut()>(suite: &mut Suite, name: &str, fn_: F) {
173 suite.run(name, Options::default(), "<inline>", fn_);
174}
175
176fn run_loop<F: FnMut()>(opts: Options, fn_: &mut F) -> (Vec<f64>, u32, f64) {
179 let batch_size = match opts.batch_size {
181 BatchSize::Fixed(n) => n.max(1),
182 BatchSize::Auto => calibrate_batch_size(fn_),
183 };
184
185 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
213fn 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
236fn 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 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 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 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 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 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 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, };
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}