Skip to main content

aver/bench/
manifest.rs

1//! Scenario manifest: TOML on disk, struct in memory.
2//!
3//! ```toml
4//! # bench/scenarios/factorial.toml
5//! name  = "factorial"             # optional, defaults to file stem
6//! entry = "/tmp/factorial.av"     # absolute or relative to manifest dir
7//! iterations = 50
8//! warmup     = 5
9//! args       = []                 # CLI args passed to `main`, default empty
10//!
11//! [expected]
12//! # response_bytes = 23           # exact match (optional)
13//! # response_bytes_min = 100      # range match (optional)
14//! # response_bytes_max = 200
15//! ```
16//!
17//! Keep the format dumb: TOML, flat, no env vars, no string templates.
18//! A scenario is meant to be the simplest reproducible thing that pins
19//! a measurement, not a generic test runner. If a scenario needs more
20//! than this, that's a sign to write a small Aver harness fn in the
21//! `entry` file rather than to grow this struct.
22
23use std::path::{Path, PathBuf};
24
25#[derive(Debug)]
26pub enum ManifestError {
27    Read(String),
28    Parse(String),
29    Validate(String),
30}
31
32impl std::fmt::Display for ManifestError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Read(m) | Self::Parse(m) | Self::Validate(m) => f.write_str(m),
36        }
37    }
38}
39
40#[derive(Debug, Clone)]
41pub struct Manifest {
42    pub name: String,
43    /// Absolute path to the entry `.av` file.
44    pub entry: PathBuf,
45    pub iterations: usize,
46    pub warmup: usize,
47    pub args: Vec<String>,
48    pub expected: ExpectedShape,
49    pub tolerance: Tolerance,
50}
51
52/// Bench targets — picks which backend runs the scenario.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BenchTarget {
55    /// In-process VM via `vm::compile_program_with_modules`.
56    Vm,
57    /// Native Rust binary produced by `aver compile --target rust` +
58    /// `cargo build --release`. Subprocess spawn per iteration.
59    Rust,
60    /// In-process wasm-gc backend via wasmtime with the GC + tail-call
61    /// proposals enabled. Compiles once with `aver compile --target
62    /// wasm-gc`, instantiates + invokes per iteration. wasmtime's GC
63    /// heap is the ceiling for alloc-heavy scenarios — the V8 column
64    /// (below) is the production-relevant number for any wasm-gc
65    /// deploy that lands in a browser, Cloudflare Workers, Node, Bun,
66    /// or Deno. Keep both around so the gap is visible.
67    WasmGc,
68    /// Same wasm-gc bytes (`aver compile --target wasm-gc`), executed
69    /// under V8 via Node 22+ and `tools/wasm-gc-bench-v8.mjs`. Spawns
70    /// one Node process per scenario, parses raw sample timings off
71    /// stdout, then stitches the full `BenchReport` Rust-side. Memory
72    /// `project_v8_vs_wasmtime_gc.md` documents the engine gap that
73    /// motivates the dual column (string_interp is 2300× faster on V8
74    /// than on wasmtime as of 0.17.2). Requires `node` v22+ on PATH
75    /// or under `~/.nvm/versions/node/v22.*` — earlier Nodes ship a
76    /// V8 that rejects packed `i8` arrays.
77    WasmGcV8,
78}
79
80impl BenchTarget {
81    pub fn parse(s: &str) -> Result<Self, String> {
82        match s {
83            "vm" => Ok(Self::Vm),
84            "wasm-gc" => Ok(Self::WasmGc),
85            "wasm-gc-v8" => Ok(Self::WasmGcV8),
86            "rust" => Ok(Self::Rust),
87            other => Err(format!(
88                "unknown bench target '{}'; expected one of: vm, wasm-gc, wasm-gc-v8, rust",
89                other
90            )),
91        }
92    }
93
94    pub const fn name(self) -> &'static str {
95        match self {
96            Self::Vm => "vm",
97            Self::WasmGc => "wasm-gc",
98            Self::WasmGcV8 => "wasm-gc-v8",
99            Self::Rust => "rust",
100        }
101    }
102}
103
104/// Per-metric regression tolerances used by `--compare baseline.json`.
105/// Defaults are deliberately loose for 0.15.1 — the bench harness is new
106/// and machines vary. Tighten per-scenario in TOML once a baseline is
107/// stable on the target machine.
108#[derive(Debug, Clone, Copy)]
109pub struct Tolerance {
110    /// Maximum allowed `p50_ms` increase as a percentage of baseline.
111    /// Default 20.0 (i.e. +20% over baseline counts as a regression).
112    pub wall_time_p50_pct: f64,
113    /// Maximum allowed `p95_ms` increase, percentage. Default 30.0 —
114    /// p95 is noisier than p50 by nature, so the bar is looser.
115    pub wall_time_p95_pct: f64,
116}
117
118impl Default for Tolerance {
119    fn default() -> Self {
120        Self {
121            wall_time_p50_pct: 20.0,
122            wall_time_p95_pct: 30.0,
123        }
124    }
125}
126
127#[derive(Debug, Clone, Default)]
128pub struct ExpectedShape {
129    pub response_bytes: Option<usize>,
130    pub response_bytes_min: Option<usize>,
131    pub response_bytes_max: Option<usize>,
132}
133
134impl Manifest {
135    pub fn load(path: &Path) -> Result<Self, ManifestError> {
136        let raw = std::fs::read_to_string(path)
137            .map_err(|e| ManifestError::Read(format!("cannot read '{}': {}", path.display(), e)))?;
138        let table: toml::Value = raw.parse().map_err(|e: toml::de::Error| {
139            ManifestError::Parse(format!("{}: {}", path.display(), e))
140        })?;
141        let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
142        Self::from_toml(&table, path, manifest_dir)
143    }
144
145    fn from_toml(
146        table: &toml::Value,
147        manifest_path: &Path,
148        manifest_dir: &Path,
149    ) -> Result<Self, ManifestError> {
150        let entry_raw = table.get("entry").and_then(|v| v.as_str()).ok_or_else(|| {
151            ManifestError::Validate(format!(
152                "{}: missing required `entry = \"...\"`",
153                manifest_path.display()
154            ))
155        })?;
156        let entry_path = Path::new(entry_raw);
157        let entry = if entry_path.is_absolute() {
158            entry_path.to_path_buf()
159        } else {
160            manifest_dir.join(entry_path)
161        };
162        if !entry.exists() {
163            return Err(ManifestError::Validate(format!(
164                "{}: entry '{}' does not exist (resolved to '{}')",
165                manifest_path.display(),
166                entry_raw,
167                entry.display()
168            )));
169        }
170
171        let name = table
172            .get("name")
173            .and_then(|v| v.as_str())
174            .map(|s| s.to_string())
175            .unwrap_or_else(|| {
176                manifest_path
177                    .file_stem()
178                    .and_then(|s| s.to_str())
179                    .unwrap_or("scenario")
180                    .to_string()
181            });
182
183        let iterations = table
184            .get("iterations")
185            .and_then(|v| v.as_integer())
186            .unwrap_or(10) as usize;
187        let warmup = table
188            .get("warmup")
189            .and_then(|v| v.as_integer())
190            .unwrap_or(1) as usize;
191
192        if iterations == 0 {
193            return Err(ManifestError::Validate(format!(
194                "{}: `iterations` must be > 0",
195                manifest_path.display()
196            )));
197        }
198
199        let args = table
200            .get("args")
201            .and_then(|v| v.as_array())
202            .map(|arr| {
203                arr.iter()
204                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
205                    .collect()
206            })
207            .unwrap_or_default();
208
209        let expected = match table.get("expected") {
210            Some(toml::Value::Table(t)) => ExpectedShape {
211                response_bytes: t
212                    .get("response_bytes")
213                    .and_then(|v| v.as_integer())
214                    .map(|n| n as usize),
215                response_bytes_min: t
216                    .get("response_bytes_min")
217                    .and_then(|v| v.as_integer())
218                    .map(|n| n as usize),
219                response_bytes_max: t
220                    .get("response_bytes_max")
221                    .and_then(|v| v.as_integer())
222                    .map(|n| n as usize),
223            },
224            _ => ExpectedShape::default(),
225        };
226
227        let tolerance = match table.get("tolerance") {
228            Some(toml::Value::Table(t)) => {
229                let mut tol = Tolerance::default();
230                if let Some(v) = t.get("wall_time_p50_pct").and_then(|v| v.as_float()) {
231                    tol.wall_time_p50_pct = v;
232                }
233                if let Some(v) = t.get("wall_time_p95_pct").and_then(|v| v.as_float()) {
234                    tol.wall_time_p95_pct = v;
235                }
236                tol
237            }
238            _ => Tolerance::default(),
239        };
240
241        Ok(Manifest {
242            name,
243            entry,
244            iterations,
245            warmup,
246            args,
247            expected,
248            tolerance,
249        })
250    }
251}