//! Benchmark harness (spec ยง25): a repository of 10 000 files and 5 000 //! changes, then the everyday operations timed against thresholds. //! //! Not part of the ordinary test run. Run it with //! //! cargo test --release -p oxy-core --test bench -- --ignored --nocapture //! //! `OXY_BENCH_FILES` and `OXY_BENCH_CHANGES` change the size and //! `OXY_BENCH_DIR` the disk. The table it prints is what //! `docs/benchmarks.md` records. Thresholds are for the default size on a //! developer machine; they catch regressions of an order of magnitude, //! not noise. use oxy_core::repo::Repo; use std::path::Path; use std::time::{Duration, Instant}; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn env_usize(name: &str, default: usize) -> usize { std::env::var(name) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(default) } fn write(root: &Path, rel: &str, content: &str) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } /// Peak resident memory of this process, from /proc (Linux only). fn peak_rss_mib() -> Option { let s = std::fs::read_to_string("/proc/self/status").ok()?; let kb: f64 = s .lines() .find(|l| l.starts_with("VmHWM:"))? .split_whitespace() .nth(1)? .parse() .ok()?; Some(kb / 1024.0) } struct Row { what: String, took: Duration, limit: Duration, } fn time( rows: &mut Vec, what: impl Into, limit_ms: u64, f: impl FnOnce() -> T, ) -> T { let t = Instant::now(); let out = f(); rows.push(Row { what: what.into(), took: t.elapsed(), limit: Duration::from_millis(limit_ms), }); out } fn file_name(i: usize) -> String { format!("src/d{:03}/f{:05}.txt", i / 100, i) } #[test] #[ignore] fn bench_ten_thousand_files_five_thousand_changes() { hermetic(); let files = env_usize("OXY_BENCH_FILES", 10_000); let changes = env_usize("OXY_BENCH_CHANGES", 5_000); let dir = match std::env::var_os("OXY_BENCH_DIR") { Some(d) => tempfile::tempdir_in(d).unwrap(), None => tempfile::tempdir().unwrap(), }; let root = dir.path(); let work = root.join("work"); let repo = Repo::init(&work).unwrap().with_home(root.join("home")); let mut rows = Vec::new(); // Build: one import of every file, then small changes; one file in // ten changes is the hot file, so blame has a long history to walk. for i in 0..files { write( &work, &file_name(i), &format!("file {i}\n{}", "some line of text\n".repeat(20)), ); } write(&work, "hot.txt", ""); time( &mut rows, format!("commit: import {files} files"), 60_000, || repo.commit("import", &[]).unwrap(), ); let mut hot = String::new(); let build = Instant::now(); for c in 1..changes { if c % 10 == 0 { hot.push_str(&format!("line added by change {c}\n")); write(&work, "hot.txt", &hot); } else { let i = (c * 7919) % files; write( &work, &file_name(i), &format!( "file {i} edited by change {c}\n{}", "some line of text\n".repeat(20) ), ); } repo.commit(&format!("change {c}"), &[]).unwrap(); } let per_commit = build.elapsed() / (changes.max(2) - 1) as u32; rows.push(Row { what: format!("commit: one file (mean of {})", changes - 1), took: per_commit, limit: Duration::from_millis(250), }); // Everyday operations on the finished repository. time( &mut rows, "status: clean working copy (stat crawl)", 2_000, || assert!(!repo.snapshot("oxy status").unwrap().changed), ); for i in 0..10 { write(&work, &file_name(i * 13), &format!("ten-file edit {i}\n")); } time(&mut rows, "status: 10 files modified", 2_000, || { assert!(repo.snapshot("oxy status").unwrap().changed) }); time(&mut rows, "commit: 10 files", 500, || { repo.commit("ten files", &[]).unwrap() }); let log = time(&mut rows, "log: whole bookmark", 2_000, || { repo.log().unwrap() }); assert!(log.len() >= changes, "{} entries", log.len()); let blame = time( &mut rows, format!("blame: hot file ({} lines)", changes / 10), 2_000, || repo.blame("hot.txt").unwrap(), ); assert_eq!(blame.len(), (changes - 1) / 10); time(&mut rows, "blame: ordinary file", 1_000, || { repo.blame(&file_name(1)).unwrap() }); time(&mut rows, "checkpoint", 10_000, || { repo.checkpoint().unwrap() }); time(&mut rows, "status after checkpoint", 2_000, || { repo.snapshot("oxy status").unwrap() }); // Sync over a local path: a full push into an empty hosted repository, then a clone. let origin = root.join("origin"); Repo::init_bare_unbound(&origin).unwrap(); let mut repo = repo; repo.config .set("remote.origin.url", &origin.to_string_lossy()) .unwrap(); repo.save_config().unwrap(); time( &mut rows, "push: everything to an empty remote", 60_000, || assert!(repo.push("origin", false).unwrap().iter().all(|r| r.ok)), ); time(&mut rows, "push: up to date", 1_000, || { repo.push("origin", false).unwrap() }); let clone = time( &mut rows, "clone: everything, with checkout", 60_000, || Repo::clone(&origin.to_string_lossy(), &root.join("clone")).unwrap(), ); assert_eq!( clone.bookmark_heads().unwrap(), repo.bookmark_heads().unwrap() ); let rep = time(&mut rows, "fsck", 60_000, || repo.fsck().unwrap()); assert_eq!(rep.errors(), 0, "{:?}", rep.problems); time(&mut rows, "gc", 60_000, || repo.gc(true).unwrap()); time(&mut rows, "status after gc", 2_000, || { repo.snapshot("oxy status").unwrap() }); eprintln!("\n| Operation ({files} files, {changes} changes) | Time | Threshold | |"); eprintln!("|---|---:|---:|---|"); for r in &rows { let verdict = if r.took > r.limit { "over" } else { "ok" }; eprintln!( "| {} | {:.1} ms | {} ms | {verdict} |", r.what, r.took.as_secs_f64() * 1000.0, r.limit.as_millis() ); } if let Some(mib) = peak_rss_mib() { eprintln!("\nPeak resident memory of the whole run: {mib:.0} MiB"); } let slow: Vec<&Row> = rows.iter().filter(|r| r.took > r.limit).collect(); assert!( slow.is_empty(), "over threshold: {:?}", slow.iter().map(|r| (&r.what, r.took)).collect::>() ); }