use std::collections::BTreeMap;
use std::hint::black_box;
use std::path::Path;
use std::time::Instant;
use serde::{Deserialize, Serialize};
pub const TOLERANCE_PCT: f64 = 5.0;
const BENCH_ITERATIONS: u32 = 4000;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BenchSet {
pub metrics: BTreeMap<String, f64>,
}
impl BenchSet {
#[cfg(test)]
fn of(pairs: &[(&str, f64)]) -> Self {
BenchSet {
metrics: pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Finding {
Regression {
metric: String,
baseline_ns: f64,
current_ns: f64,
pct: f64,
},
Missing { metric: String },
Unbaselined { metric: String, current_ns: f64 },
}
impl Finding {
#[must_use]
pub fn is_failure(&self) -> bool {
matches!(self, Finding::Regression { .. } | Finding::Missing { .. })
}
}
#[must_use]
pub fn compare(baseline: &BenchSet, current: &BenchSet, tol_pct: f64) -> Vec<Finding> {
let mut findings = Vec::new();
for (metric, &base_ns) in &baseline.metrics {
match current.metrics.get(metric) {
None => findings.push(Finding::Missing {
metric: metric.clone(),
}),
Some(&cur_ns) => {
let pct = (cur_ns - base_ns) / base_ns * 100.0;
if pct > tol_pct {
findings.push(Finding::Regression {
metric: metric.clone(),
baseline_ns: base_ns,
current_ns: cur_ns,
pct,
});
}
}
}
}
for (metric, &cur_ns) in ¤t.metrics {
if !baseline.metrics.contains_key(metric) {
findings.push(Finding::Unbaselined {
metric: metric.clone(),
current_ns: cur_ns,
});
}
}
findings
}
fn median_ns(mut f: impl FnMut(), iters: u32) -> f64 {
for _ in 0..(iters / 10).max(1) {
f();
}
let mut samples = Vec::with_capacity(iters as usize);
for _ in 0..iters {
let t = Instant::now();
f();
samples.push(t.elapsed().as_nanos() as f64);
}
samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN timing"));
samples[samples.len() / 2]
}
fn measure_corpus() -> BenchSet {
use smix_selector::{Modifiers, Pattern, Selector, describe_selector};
let flow_yaml = "\
appId: smix.bench.corpus
---
- launchApp
- tapOn: { id: sign-in }
- inputText: alice@example.com
- assertVisible: Welcome
- longPressOn: { id: row-3, duration: 1500 }
";
let selector = Selector::Text {
text: Pattern::text("Sign In"),
modifiers: Modifiers::default(),
};
let mut metrics = BTreeMap::new();
metrics.insert(
"parse_flow_yaml.five_step".to_string(),
median_ns(
|| {
let _ = black_box(smix_adapter_maestro::parse_flow_yaml(black_box(flow_yaml)));
},
BENCH_ITERATIONS,
),
);
metrics.insert(
"describe_selector.text".to_string(),
median_ns(
|| {
let _ = black_box(describe_selector(black_box(&selector)));
},
BENCH_ITERATIONS,
),
);
BenchSet { metrics }
}
fn load(path: &Path) -> Result<BenchSet, String> {
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_slice(&bytes).map_err(|e| format!("parse {}: {e}", path.display()))
}
fn write(path: &Path, set: &BenchSet) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
}
let json = serde_json::to_string_pretty(set).map_err(|e| format!("serialize baseline: {e}"))?;
std::fs::write(path, json + "\n").map_err(|e| format!("write {}: {e}", path.display()))
}
pub fn run(
update_baseline: bool,
current_file: Option<&Path>,
baseline_path: &Path,
) -> Result<(), String> {
let current = match current_file {
Some(p) => load(p)?,
None => measure_corpus(),
};
if update_baseline {
write(baseline_path, ¤t)?;
println!(
"smix bench: baseline updated ({} metrics) → {}",
current.metrics.len(),
baseline_path.display()
);
return Ok(());
}
let baseline = load(baseline_path).map_err(|e| {
format!("{e} — no committed baseline; run `smix bench --update-baseline` first")
})?;
let findings = compare(&baseline, ¤t, TOLERANCE_PCT);
for f in &findings {
match f {
Finding::Regression {
metric,
baseline_ns,
current_ns,
pct,
} => {
println!("REGRESSION {metric}: {baseline_ns:.1}ns → {current_ns:.1}ns (+{pct:.1}%)")
}
Finding::Missing { metric } => {
println!("MISSING {metric}: in baseline, not measured this run")
}
Finding::Unbaselined { metric, current_ns } => {
println!("NEW {metric}: {current_ns:.1}ns (no baseline; run --update-baseline)")
}
}
}
let failures = findings.iter().filter(|f| f.is_failure()).count();
if failures == 0 {
println!(
"smix bench: {} metric(s) within {TOLERANCE_PCT:.0}% of baseline",
current.metrics.len()
);
Ok(())
} else {
Err(format!(
"smix bench: {failures} metric(s) regressed beyond {TOLERANCE_PCT:.0}%"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 5.0;
#[test]
fn a_slowdown_within_tolerance_is_not_a_regression() {
let f = compare(
&BenchSet::of(&[("m", 100.0)]),
&BenchSet::of(&[("m", 104.0)]),
TOL,
);
assert!(f.is_empty(), "4% should be tolerated: {f:?}");
}
#[test]
fn a_slowdown_past_tolerance_is_a_regression_with_the_numbers() {
let f = compare(
&BenchSet::of(&[("m", 100.0)]),
&BenchSet::of(&[("m", 106.0)]),
TOL,
);
assert_eq!(f.len(), 1);
match &f[0] {
Finding::Regression {
metric,
baseline_ns,
current_ns,
pct,
} => {
assert_eq!(metric, "m");
assert_eq!(*baseline_ns, 100.0);
assert_eq!(*current_ns, 106.0);
assert!((*pct - 6.0).abs() < 1e-9, "pct = {pct}");
}
other => panic!("expected Regression, got {other:?}"),
}
assert!(f[0].is_failure());
}
#[test]
fn a_metric_that_got_faster_is_not_a_finding() {
let f = compare(
&BenchSet::of(&[("m", 100.0)]),
&BenchSet::of(&[("m", 50.0)]),
TOL,
);
assert!(f.is_empty(), "a speedup is not a finding: {f:?}");
}
#[test]
fn a_baseline_metric_missing_from_current_fails_rather_than_passes() {
let f = compare(
&BenchSet::of(&[("kept", 100.0), ("dropped", 100.0)]),
&BenchSet::of(&[("kept", 100.0)]),
TOL,
);
assert_eq!(f.len(), 1);
assert_eq!(
f[0],
Finding::Missing {
metric: "dropped".into()
}
);
assert!(f[0].is_failure(), "a disappeared metric must fail the gate");
}
#[test]
fn a_new_metric_without_baseline_is_flagged_but_not_a_failure() {
let f = compare(
&BenchSet::of(&[("old", 100.0)]),
&BenchSet::of(&[("old", 100.0), ("new", 42.0)]),
TOL,
);
assert_eq!(f.len(), 1);
assert_eq!(
f[0],
Finding::Unbaselined {
metric: "new".into(),
current_ns: 42.0
}
);
assert!(
!f[0].is_failure(),
"a new metric has nothing to regress against"
);
}
#[test]
fn findings_come_out_in_metric_name_order() {
let f = compare(
&BenchSet::of(&[("b", 100.0), ("a", 100.0)]),
&BenchSet::of(&[("b", 200.0), ("a", 200.0)]),
TOL,
);
let names: Vec<&str> = f
.iter()
.filter_map(|x| match x {
Finding::Regression { metric, .. } => Some(metric.as_str()),
_ => None,
})
.collect();
assert_eq!(names, ["a", "b"], "deterministic order");
}
}