//! Inner-loop evaluation benchmark: deserialize the cached dumps in `bench/fixtures/` //! once, then repeatedly run a stand-in "candidate" against each and score it with //! `harness::compare::compare()`. No engine exists yet (`roadmap.md` -> "Phase 0"), so //! the candidate is the identity function on a cloned dump; what this measures is the //! harness-side cost every real candidate will also pay -- deserialize once, clone or //! reconstruct a `Dump`-shaped result per case, and compare it -- not candidate //! semantics. //! //! `run-inner-loop.sh` runs this across thread counts 1, 2, 4, 8, 16 to build the scaling //! curve. Standalone: `cargo run --release --bin inner_loop -- --threads 4 --seconds 2`. use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use harness::compare::compare; use harness::dump::Dump; struct Args { threads: usize, seconds: f64, } fn parse_args() -> Result { let mut threads = 1usize; let mut seconds = 2.0f64; let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { match arg.as_str() { "--threads" => threads = it.next().unwrap_or_default().parse()?, "--seconds" => seconds = it.next().unwrap_or_default().parse()?, other => bail!("unrecognized argument: {other}"), } } Ok(Args { threads, seconds }) } /// A cheap per-case perturbation so `compare` walks both the passing and failing branch /// of every measurement, rather than only ever comparing a dump against an identical /// clone. fn perturb(dump: &Dump, i: usize) -> Dump { let mut out = dump.clone(); let len = out.nodes.len().max(1); if let Some(node) = out.nodes.get_mut(i % len) { node.bounding.x += if i % 7 == 0 { 0.5 } else { 0.0 }; } out } fn load_fixtures() -> Result> { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures"); let mut dumps = Vec::new(); for entry in std::fs::read_dir(&dir) .with_context(|| format!("reading fixtures from {}", dir.display()))? { let entry = entry?; if entry.path().extension().and_then(|e| e.to_str()) != Some("json") { continue; } let text = std::fs::read_to_string(entry.path())?; dumps.push(serde_json::from_str(&text)?); } if dumps.is_empty() { bail!("no fixtures in {}; run `cargo run --release --bin gen_fixtures` first", dir.display()); } Ok(dumps) } fn main() -> Result<()> { let args = parse_args()?; let dumps = Arc::new(load_fixtures()?); println!("loaded {} fixture(s)", dumps.len()); let deadline = Instant::now() + Duration::from_secs_f64(args.seconds); let counter = Arc::new(AtomicU64::new(0)); let mut handles = Vec::new(); for t in 0..args.threads { let dumps = Arc::clone(&dumps); let counter = Arc::clone(&counter); handles.push(std::thread::spawn(move || { let mut i = t; while Instant::now() < deadline { let dump = &dumps[i % dumps.len()]; let candidate = perturb(dump, i); let outcome = compare(dump, &candidate); std::hint::black_box(outcome); counter.fetch_add(1, Ordering::Relaxed); i += args.threads.max(1); } })); } let start = Instant::now(); for h in handles { h.join().expect("worker thread panicked"); } let elapsed = start.elapsed().as_secs_f64(); let total = counter.load(Ordering::Relaxed); let per_sec = total as f64 / elapsed; let result = serde_json::json!({ "threads": args.threads, "cases": total, "elapsed_s": elapsed, "cases_per_s": per_sec, "cases_per_s_per_core": per_sec / args.threads as f64, }); println!("{result}"); Ok(()) }