Skip to main content

aver/bench/
report.rs

1//! Bench report — the structured JSON shape that `aver bench` emits.
2//!
3//! This is the contract that `aver bench --compare baseline.json` (0.15.2)
4//! and the future CI gate read. Adding fields is fine, removing/renaming
5//! is a breaking change to that contract.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct BenchReport {
11    pub scenario: ScenarioMetadata,
12    /// Identifies the build that ran the bench: aver version, build
13    /// profile, target backend, plus optional version strings for
14    /// per-target runtimes (e.g. wasmtime for `wasm-local`).
15    pub backend: BackendInfo,
16    /// OS / architecture / process identity. Same JSON shape across
17    /// targets; downstream tools join on `host.os + host.arch + backend.name`
18    /// to compare like-for-like across runs.
19    pub host: HostInfo,
20    pub iterations: IterationStats,
21    /// Total stdout byte count of the last iteration. `null` in 0.15.1
22    /// (capture infrastructure lands with the runtime allocators in
23    /// 0.15.2). Used by `expected.response_bytes*` checks once populated.
24    pub response_bytes: Option<usize>,
25    /// `true` when the run satisfied every `[expected]` constraint in
26    /// the manifest. `null` when the manifest has no expectations.
27    pub expected_match: Option<bool>,
28    /// Pipeline stages that actually fired. Sourced from the pipeline's
29    /// `on_after_pass` hook so it reflects what *ran*, not what was
30    /// requested.
31    pub passes_applied: Vec<String>,
32    /// IR-level allocation counter. `null` in 0.15.1 — pending the
33    /// `aver compile --explain-allocations` work in 0.15.2.
34    pub compiler_visible_allocs: Option<usize>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BackendInfo {
39    /// Target name as parsed from `--target` (`vm` / `wasm-local` / `rust`).
40    pub name: String,
41    /// Version of the `aver` binary that ran the bench (Cargo package
42    /// version at compile time of this binary).
43    pub aver_version: String,
44    /// `"release"` or `"debug"`, derived from the calling binary's
45    /// build profile (`debug_assertions` cfg).
46    pub build: String,
47    /// wasmtime crate version when the report came from `--target=wasm-local`,
48    /// `null` otherwise.
49    pub wasmtime_version: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct HostInfo {
54    /// `"macos"` / `"linux"` / `"windows"` (from `std::env::consts::OS`).
55    pub os: String,
56    /// `"aarch64"` / `"x86_64"` / `"x86"` etc. (from `std::env::consts::ARCH`).
57    pub arch: String,
58    /// Logical CPU count from `std::thread::available_parallelism`.
59    pub cpus: usize,
60}
61
62impl BackendInfo {
63    pub fn for_target(target: crate::bench::manifest::BenchTarget) -> Self {
64        let build = if cfg!(debug_assertions) {
65            "debug"
66        } else {
67            "release"
68        };
69        let wasmtime_version = match target {
70            crate::bench::manifest::BenchTarget::WasmLocal => Some(WASMTIME_VERSION.to_string()),
71            _ => None,
72        };
73        Self {
74            name: target.name().to_string(),
75            aver_version: env!("CARGO_PKG_VERSION").to_string(),
76            build: build.to_string(),
77            wasmtime_version,
78        }
79    }
80}
81
82impl HostInfo {
83    pub fn capture() -> Self {
84        let cpus = std::thread::available_parallelism()
85            .map(|n| n.get())
86            .unwrap_or(1);
87        Self {
88            os: std::env::consts::OS.to_string(),
89            arch: std::env::consts::ARCH.to_string(),
90            cpus,
91        }
92    }
93}
94
95/// Wasmtime version string compiled into the bench reports. Bumped
96/// alongside the `wasmtime` dependency in `Cargo.toml`; downstream
97/// tools that compare bench numbers across runs use it to detect
98/// runtime upgrades that might explain a delta.
99const WASMTIME_VERSION: &str = "29";
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ScenarioMetadata {
103    pub name: String,
104    pub entry: String,
105    pub target: String,
106    pub iterations_count: usize,
107    pub warmup_count: usize,
108}
109
110/// Per-iteration wall-clock stats in milliseconds.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct IterationStats {
113    pub min_ms: f64,
114    pub max_ms: f64,
115    pub mean_ms: f64,
116    pub p50_ms: f64,
117    pub p95_ms: f64,
118    pub p99_ms: f64,
119}
120
121/// Render `report` as a multi-line human-readable summary (default
122/// `aver bench` output). The shape is deliberately compact — bench
123/// engineers want one glance to read pass list + percentiles, not
124/// a wall of pretty-printed JSON.
125pub fn format_human(report: &BenchReport) -> String {
126    use std::fmt::Write;
127
128    fn fmt_ms(ms: f64) -> String {
129        if ms >= 1.0 {
130            format!("{:.2}ms", ms)
131        } else {
132            format!("{:.0}µs", ms * 1000.0)
133        }
134    }
135
136    let mut out = String::new();
137    let s = &report.scenario;
138    let b = &report.backend;
139    let h = &report.host;
140    let it = &report.iterations;
141    writeln!(out, "{} [{}]", s.name, s.target).ok();
142    writeln!(out, "  entry:        {}", s.entry).ok();
143    let mut backend_line = format!("aver {} ({})", b.aver_version, b.build);
144    if let Some(wt) = &b.wasmtime_version {
145        backend_line.push_str(&format!(", wasmtime {}", wt));
146    }
147    writeln!(out, "  backend:      {}", backend_line).ok();
148    writeln!(out, "  host:         {}/{} ({} cpus)", h.os, h.arch, h.cpus).ok();
149    writeln!(
150        out,
151        "  iterations:   {} (warmup {})",
152        s.iterations_count, s.warmup_count
153    )
154    .ok();
155    writeln!(
156        out,
157        "  passes:       {}",
158        if report.passes_applied.is_empty() {
159            "(none)".to_string()
160        } else {
161            report.passes_applied.join(", ")
162        }
163    )
164    .ok();
165    writeln!(
166        out,
167        "  wall_time:    min={}  p50={}  p95={}  max={}  mean={}",
168        fmt_ms(it.min_ms),
169        fmt_ms(it.p50_ms),
170        fmt_ms(it.p95_ms),
171        fmt_ms(it.max_ms),
172        fmt_ms(it.mean_ms),
173    )
174    .ok();
175    if let Some(bytes) = report.response_bytes {
176        writeln!(out, "  response:     {} bytes", bytes).ok();
177    }
178    if let Some(matched) = report.expected_match {
179        writeln!(
180            out,
181            "  expected:     {}",
182            if matched { "ok" } else { "MISMATCH" }
183        )
184        .ok();
185    }
186    out
187}
188
189impl IterationStats {
190    pub fn from_samples(samples: &[f64]) -> Self {
191        assert!(!samples.is_empty(), "IterationStats requires ≥1 sample");
192        let mut sorted: Vec<f64> = samples.to_vec();
193        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
194        let n = sorted.len();
195        let percentile = |p: f64| -> f64 {
196            // Nearest-rank percentile — small N so the choice between
197            // nearest-rank and linear-interp doesn't matter much; nearest-
198            // rank is dependency-free and reproducible.
199            let idx = ((p / 100.0) * (n as f64)).ceil() as usize;
200            let idx = idx.saturating_sub(1).min(n - 1);
201            sorted[idx]
202        };
203        IterationStats {
204            min_ms: *sorted.first().unwrap(),
205            max_ms: *sorted.last().unwrap(),
206            mean_ms: sorted.iter().sum::<f64>() / (n as f64),
207            p50_ms: percentile(50.0),
208            p95_ms: percentile(95.0),
209            p99_ms: percentile(99.0),
210        }
211    }
212}