Benchmark harness and first numbers 1d360c91a289
crates/oxy-core/tests/bench.rs builds 10 000 files and 5 000 changes through the library, then times commit, status, log, blame, checkpoint, push, clone, fsck and gc against thresholds, with peak memory. It is an ignored test, run by hand; docs/benchmarks.md records the first run. Sync, log and maintenance are fine. Status and commit are not: a commit takes 3.8 s and a clean status 3.0 s at that size, because every snapshot loads and materializes the whole base state. Backlog item O10 takes that on; the thresholds stay so the harness shows the gap.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 05:37 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1d360c91a28966769c98cce7bbe563806655418237ff8b80a435907ca878d1dc- Depends on
- 37c299cec358 75036530b621 ff0af1ee299d
- Depended on by
- 30fc48081b94 87828a0b1f36 dd6147dcf9c2 4f264d6f20b6 0389c3e82f6c fddf64baa3d2 e96a430c78f3 5ae7533f1888 33472ccc08cc cd4eb55e3b3d b30a880fa409 e226a6cc2f71 10b4fd430eab 1ec3f008e96a 77445673174b 3604179679eb cd19603782bf e82591ec33a4 bd9414401fdc e4b8bcf04a86 60622d23b9e4 75b6c8665219 d77e1702d770 36695c5e02ef da652d6392d8 57d8dc05c9d4 b493d993a9df a87c7b9d563a 2c75672fb2fe 519c7c061bcb 598009a75f6c 01a47cd7f0c8 17f94b13ae54 192fad1e46a2 206c76cad4bf 8b276e0106d1 69816d688300 0554aaa7ddef eaf3e9068180 d3cd7ef95da6 a4fad5b085c7 c5ce0367eddf da868d5d2646 86e1824f2d0b 2951f8023f40 958fb27dda7c 8ab265dc4a51 cc7a00565161 0bdc3f91a39f 0bb9413f0118 b8a0399319fc 59171dcff221
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 3 atoms, 2 tree operations, 3 files
Browse the tree at this change
added crates/oxy-core/tests/bench.rs +228 −0
| 1 | + | //! Benchmark harness (spec §25): a repository of 10 000 files and 5 000 |
|
| 2 | + | //! changes, then the everyday operations timed against thresholds. |
|
| 3 | + | //! |
|
| 4 | + | //! Not part of the ordinary test run. Run it with |
|
| 5 | + | //! |
|
| 6 | + | //! cargo test --release -p oxy-core --test bench -- --ignored --nocapture |
|
| 7 | + | //! |
|
| 8 | + | //! `OXY_BENCH_FILES` and `OXY_BENCH_CHANGES` change the size and |
|
| 9 | + | //! `OXY_BENCH_DIR` the disk. The table it prints is what |
|
| 10 | + | //! `docs/benchmarks.md` records. Thresholds are for the default size on a |
|
| 11 | + | //! developer machine; they catch regressions of an order of magnitude, |
|
| 12 | + | //! not noise. |
|
| 13 | + | ||
| 14 | + | use oxy_core::repo::Repo; |
|
| 15 | + | use std::path::Path; |
|
| 16 | + | use std::time::{Duration, Instant}; |
|
| 17 | + | ||
| 18 | + | fn hermetic() { |
|
| 19 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 20 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 21 | + | ONCE.call_once(|| { |
|
| 22 | + | let d = tempfile::tempdir().unwrap(); |
|
| 23 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 24 | + | DIR.set(d).ok(); |
|
| 25 | + | }); |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | fn env_usize(name: &str, default: usize) -> usize { |
|
| 29 | + | std::env::var(name) |
|
| 30 | + | .ok() |
|
| 31 | + | .and_then(|v| v.parse().ok()) |
|
| 32 | + | .unwrap_or(default) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | fn write(root: &Path, rel: &str, content: &str) { |
|
| 36 | + | let p = root.join(rel); |
|
| 37 | + | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 38 | + | std::fs::write(p, content).unwrap(); |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// Peak resident memory of this process, from /proc (Linux only). |
|
| 42 | + | fn peak_rss_mib() -> Option<f64> { |
|
| 43 | + | let s = std::fs::read_to_string("/proc/self/status").ok()?; |
|
| 44 | + | let kb: f64 = s |
|
| 45 | + | .lines() |
|
| 46 | + | .find(|l| l.starts_with("VmHWM:"))? |
|
| 47 | + | .split_whitespace() |
|
| 48 | + | .nth(1)? |
|
| 49 | + | .parse() |
|
| 50 | + | .ok()?; |
|
| 51 | + | Some(kb / 1024.0) |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | struct Row { |
|
| 55 | + | what: String, |
|
| 56 | + | took: Duration, |
|
| 57 | + | limit: Duration, |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | fn time<T>( |
|
| 61 | + | rows: &mut Vec<Row>, |
|
| 62 | + | what: impl Into<String>, |
|
| 63 | + | limit_ms: u64, |
|
| 64 | + | f: impl FnOnce() -> T, |
|
| 65 | + | ) -> T { |
|
| 66 | + | let t = Instant::now(); |
|
| 67 | + | let out = f(); |
|
| 68 | + | rows.push(Row { |
|
| 69 | + | what: what.into(), |
|
| 70 | + | took: t.elapsed(), |
|
| 71 | + | limit: Duration::from_millis(limit_ms), |
|
| 72 | + | }); |
|
| 73 | + | out |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | fn file_name(i: usize) -> String { |
|
| 77 | + | format!("src/d{:03}/f{:05}.txt", i / 100, i) |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | #[test] |
|
| 81 | + | #[ignore] |
|
| 82 | + | fn bench_ten_thousand_files_five_thousand_changes() { |
|
| 83 | + | hermetic(); |
|
| 84 | + | let files = env_usize("OXY_BENCH_FILES", 10_000); |
|
| 85 | + | let changes = env_usize("OXY_BENCH_CHANGES", 5_000); |
|
| 86 | + | let dir = match std::env::var_os("OXY_BENCH_DIR") { |
|
| 87 | + | Some(d) => tempfile::tempdir_in(d).unwrap(), |
|
| 88 | + | None => tempfile::tempdir().unwrap(), |
|
| 89 | + | }; |
|
| 90 | + | let root = dir.path(); |
|
| 91 | + | let work = root.join("work"); |
|
| 92 | + | let repo = Repo::init(&work).unwrap().with_home(root.join("home")); |
|
| 93 | + | let mut rows = Vec::new(); |
|
| 94 | + | ||
| 95 | + | // Build: one import of every file, then small changes; one file in |
|
| 96 | + | // ten changes is the hot file, so blame has a long history to walk. |
|
| 97 | + | for i in 0..files { |
|
| 98 | + | write( |
|
| 99 | + | &work, |
|
| 100 | + | &file_name(i), |
|
| 101 | + | &format!("file {i}\n{}", "some line of text\n".repeat(20)), |
|
| 102 | + | ); |
|
| 103 | + | } |
|
| 104 | + | write(&work, "hot.txt", ""); |
|
| 105 | + | time( |
|
| 106 | + | &mut rows, |
|
| 107 | + | format!("commit: import {files} files"), |
|
| 108 | + | 60_000, |
|
| 109 | + | || repo.commit("import", &[]).unwrap(), |
|
| 110 | + | ); |
|
| 111 | + | let mut hot = String::new(); |
|
| 112 | + | let build = Instant::now(); |
|
| 113 | + | for c in 1..changes { |
|
| 114 | + | if c % 10 == 0 { |
|
| 115 | + | hot.push_str(&format!("line added by change {c}\n")); |
|
| 116 | + | write(&work, "hot.txt", &hot); |
|
| 117 | + | } else { |
|
| 118 | + | let i = (c * 7919) % files; |
|
| 119 | + | write( |
|
| 120 | + | &work, |
|
| 121 | + | &file_name(i), |
|
| 122 | + | &format!( |
|
| 123 | + | "file {i} edited by change {c}\n{}", |
|
| 124 | + | "some line of text\n".repeat(20) |
|
| 125 | + | ), |
|
| 126 | + | ); |
|
| 127 | + | } |
|
| 128 | + | repo.commit(&format!("change {c}"), &[]).unwrap(); |
|
| 129 | + | } |
|
| 130 | + | let per_commit = build.elapsed() / (changes.max(2) - 1) as u32; |
|
| 131 | + | rows.push(Row { |
|
| 132 | + | what: format!("commit: one file (mean of {})", changes - 1), |
|
| 133 | + | took: per_commit, |
|
| 134 | + | limit: Duration::from_millis(250), |
|
| 135 | + | }); |
|
| 136 | + | ||
| 137 | + | // Everyday operations on the finished repository. |
|
| 138 | + | time( |
|
| 139 | + | &mut rows, |
|
| 140 | + | "status: clean working copy (stat crawl)", |
|
| 141 | + | 2_000, |
|
| 142 | + | || assert!(!repo.snapshot("oxy status").unwrap().changed), |
|
| 143 | + | ); |
|
| 144 | + | for i in 0..10 { |
|
| 145 | + | write(&work, &file_name(i * 13), &format!("ten-file edit {i}\n")); |
|
| 146 | + | } |
|
| 147 | + | time(&mut rows, "status: 10 files modified", 2_000, || { |
|
| 148 | + | assert!(repo.snapshot("oxy status").unwrap().changed) |
|
| 149 | + | }); |
|
| 150 | + | time(&mut rows, "commit: 10 files", 500, || { |
|
| 151 | + | repo.commit("ten files", &[]).unwrap() |
|
| 152 | + | }); |
|
| 153 | + | let log = time(&mut rows, "log: whole bookmark", 2_000, || { |
|
| 154 | + | repo.log().unwrap() |
|
| 155 | + | }); |
|
| 156 | + | assert!(log.len() >= changes, "{} entries", log.len()); |
|
| 157 | + | let blame = time( |
|
| 158 | + | &mut rows, |
|
| 159 | + | format!("blame: hot file ({} lines)", changes / 10), |
|
| 160 | + | 2_000, |
|
| 161 | + | || repo.blame("hot.txt").unwrap(), |
|
| 162 | + | ); |
|
| 163 | + | assert_eq!(blame.len(), (changes - 1) / 10); |
|
| 164 | + | time(&mut rows, "blame: ordinary file", 1_000, || { |
|
| 165 | + | repo.blame(&file_name(1)).unwrap() |
|
| 166 | + | }); |
|
| 167 | + | time(&mut rows, "checkpoint", 10_000, || { |
|
| 168 | + | repo.checkpoint().unwrap() |
|
| 169 | + | }); |
|
| 170 | + | time(&mut rows, "status after checkpoint", 2_000, || { |
|
| 171 | + | repo.snapshot("oxy status").unwrap() |
|
| 172 | + | }); |
|
| 173 | + | ||
| 174 | + | // Sync over a local path: a full push into an empty hosted repository, then a clone. |
|
| 175 | + | let origin = root.join("origin"); |
|
| 176 | + | Repo::init_bare_unbound(&origin).unwrap(); |
|
| 177 | + | let mut repo = repo; |
|
| 178 | + | repo.config |
|
| 179 | + | .set("remote.origin.url", &origin.to_string_lossy()) |
|
| 180 | + | .unwrap(); |
|
| 181 | + | repo.save_config().unwrap(); |
|
| 182 | + | time( |
|
| 183 | + | &mut rows, |
|
| 184 | + | "push: everything to an empty remote", |
|
| 185 | + | 60_000, |
|
| 186 | + | || assert!(repo.push("origin", false).unwrap().iter().all(|r| r.ok)), |
|
| 187 | + | ); |
|
| 188 | + | time(&mut rows, "push: up to date", 1_000, || { |
|
| 189 | + | repo.push("origin", false).unwrap() |
|
| 190 | + | }); |
|
| 191 | + | let clone = time( |
|
| 192 | + | &mut rows, |
|
| 193 | + | "clone: everything, with checkout", |
|
| 194 | + | 60_000, |
|
| 195 | + | || Repo::clone(&origin.to_string_lossy(), &root.join("clone")).unwrap(), |
|
| 196 | + | ); |
|
| 197 | + | assert_eq!( |
|
| 198 | + | clone.bookmark_heads().unwrap(), |
|
| 199 | + | repo.bookmark_heads().unwrap() |
|
| 200 | + | ); |
|
| 201 | + | let rep = time(&mut rows, "fsck", 60_000, || repo.fsck().unwrap()); |
|
| 202 | + | assert_eq!(rep.errors(), 0, "{:?}", rep.problems); |
|
| 203 | + | time(&mut rows, "gc", 60_000, || repo.gc(true).unwrap()); |
|
| 204 | + | time(&mut rows, "status after gc", 2_000, || { |
|
| 205 | + | repo.snapshot("oxy status").unwrap() |
|
| 206 | + | }); |
|
| 207 | + | ||
| 208 | + | eprintln!("\n| Operation ({files} files, {changes} changes) | Time | Threshold | |"); |
|
| 209 | + | eprintln!("|---|---:|---:|---|"); |
|
| 210 | + | for r in &rows { |
|
| 211 | + | let verdict = if r.took > r.limit { "over" } else { "ok" }; |
|
| 212 | + | eprintln!( |
|
| 213 | + | "| {} | {:.1} ms | {} ms | {verdict} |", |
|
| 214 | + | r.what, |
|
| 215 | + | r.took.as_secs_f64() * 1000.0, |
|
| 216 | + | r.limit.as_millis() |
|
| 217 | + | ); |
|
| 218 | + | } |
|
| 219 | + | if let Some(mib) = peak_rss_mib() { |
|
| 220 | + | eprintln!("\nPeak resident memory of the whole run: {mib:.0} MiB"); |
|
| 221 | + | } |
|
| 222 | + | let slow: Vec<&Row> = rows.iter().filter(|r| r.took > r.limit).collect(); |
|
| 223 | + | assert!( |
|
| 224 | + | slow.is_empty(), |
|
| 225 | + | "over threshold: {:?}", |
|
| 226 | + | slow.iter().map(|r| (&r.what, r.took)).collect::<Vec<_>>() |
|
| 227 | + | ); |
|
| 228 | + | } |
added docs/benchmarks.md +71 −0
| 1 | + | # Benchmarks |
|
| 2 | + | ||
| 3 | + | The harness is `crates/oxy-core/tests/bench.rs` (spec §25). It builds a |
|
| 4 | + | repository through the library, one commit at a time, then times the |
|
| 5 | + | everyday operations against thresholds: |
|
| 6 | + | ||
| 7 | + | OXY_BENCH_DIR=~/.cache/oxy-bench \ |
|
| 8 | + | cargo test --release -p oxy-core --test bench -- --ignored --nocapture |
|
| 9 | + | ||
| 10 | + | `OXY_BENCH_FILES` (default 10 000) and `OXY_BENCH_CHANGES` (default |
|
| 11 | + | 5 000) set the size. The data lands under `OXY_BENCH_DIR`; `/tmp` is often |
|
| 12 | + | a RAM disk, which hides the cost of `fsync`, so measure on a real disk. |
|
| 13 | + | ||
| 14 | + | ## 2026-09-28: first run |
|
| 15 | + | ||
| 16 | + | Machine: 8 cores, x86-64, NVMe (ext4 on LVM), release build, warm cache. |
|
| 17 | + | Files are 21 lines each in 100 directories; one change in ten appends a |
|
| 18 | + | line to `hot.txt`, the rest rewrite one ordinary file. |
|
| 19 | + | ||
| 20 | + | | Operation (10 000 files, 5 000 changes) | Time | Threshold | | |
|
| 21 | + | |---|---:|---:|---| |
|
| 22 | + | | commit: import 10 000 files | 1 031 ms | 60 000 ms | ok | |
|
| 23 | + | | commit: one file (mean of 4 999) | 3 761 ms | 250 ms | **over** | |
|
| 24 | + | | status: clean working copy (stat crawl) | 2 977 ms | 2 000 ms | **over** | |
|
| 25 | + | | status: 10 files modified | 3 062 ms | 2 000 ms | **over** | |
|
| 26 | + | | commit: 10 files | 9 089 ms | 500 ms | **over** | |
|
| 27 | + | | log: whole bookmark | 408 ms | 2 000 ms | ok | |
|
| 28 | + | | blame: hot file (500 lines) | 2 920 ms | 2 000 ms | **over** | |
|
| 29 | + | | blame: ordinary file | 2 927 ms | 1 000 ms | **over** | |
|
| 30 | + | | checkpoint | 596 ms | 10 000 ms | ok | |
|
| 31 | + | | status after checkpoint | 2 873 ms | 2 000 ms | **over** | |
|
| 32 | + | | push: everything to an empty remote | 782 ms | 60 000 ms | ok | |
|
| 33 | + | | push: up to date | 4 ms | 1 000 ms | ok | |
|
| 34 | + | | clone: everything, with checkout | 3 378 ms | 60 000 ms | ok | |
|
| 35 | + | | fsck | 7 955 ms | 60 000 ms | ok | |
|
| 36 | + | | gc | 9 489 ms | 60 000 ms | ok | |
|
| 37 | + | | status after gc | 2 829 ms | 2 000 ms | **over** | |
|
| 38 | + | ||
| 39 | + | Peak resident memory over the whole run: 1 405 MiB. The run took 5 h 14 min, |
|
| 40 | + | almost all of it building the history, because each commit costs as much |
|
| 41 | + | as a status. |
|
| 42 | + | ||
| 43 | + | Smaller runs, same machine: |
|
| 44 | + | ||
| 45 | + | | Files, changes | Mean commit | Clean status | Blame (hot) | Peak memory | |
|
| 46 | + | |---|---:|---:|---:|---:| |
|
| 47 | + | | 1 000, 200 | 90 ms | 62 ms | 62 ms | 70 MiB | |
|
| 48 | + | | 1 000, 800 | 166 ms | 130 ms | 119 ms | 118 MiB | |
|
| 49 | + | | 4 000, 200 | 343 ms | 235 ms | 239 ms | 254 MiB | |
|
| 50 | + | | 10 000, 5 000 | 3 761 ms | 2 977 ms | 2 920 ms | 1 405 MiB | |
|
| 51 | + | ||
| 52 | + | ### What it shows |
|
| 53 | + | ||
| 54 | + | Sync, log, checkpoints and repository maintenance are fine. Everything |
|
| 55 | + | that snapshots the working copy (status, commit, blame of the working |
|
| 56 | + | copy's base) grows with both the number of files and the length of the |
|
| 57 | + | history. Timing the phases of one snapshot at 4 000 files and 300 changes: |
|
| 58 | + | ||
| 59 | + | | Phase | Time | |
|
| 60 | + | |---|---:| |
|
| 61 | + | | load the base state (checkpoint plus the changes since) | 75–130 ms | |
|
| 62 | + | | materialize every file of the base state | 75 ms | |
|
| 63 | + | | walk the working copy against the manifest | 50 ms | |
|
| 64 | + | | build the working-copy change | 8 ms | |
|
| 65 | + | ||
| 66 | + | A clean status should be a stat walk and nothing else. The fix is backlog |
|
| 67 | + | item O10: skip loading and materializing when every file's stat matches a |
|
| 68 | + | manifest written for the same base, make the base state lazy for the |
|
| 69 | + | callers that need it, and cache materialized snapshots per state. The |
|
| 70 | + | thresholds above stay as they are until then, so the harness reports the |
|
| 71 | + | gap rather than hiding it. |
modified docs/backlog-overnight-2.md +1 −0
| … | 43 unchanged lines |
||
| 44 | 44 | - [ ] O2 `oxy fsck`: verify every object's hash, every change's references and signatures, the op log chain, and report precisely. |
|
| 45 | 45 | - [ ] O3 Crash safety audit: every write goes through write_atomic or the store's batch; a test that interrupts commit and push at each step and proves the repository still opens and fscks. |
|
| 46 | 46 | - [ ] O4 Benchmarks with thresholds: 10 000 files, 5 000 changes; status, commit, log, blame, push, clone; record numbers in docs. |
|
| 47 | + | - [ ] O10 Snapshot fast path (found by O4; do next on the oxy track): at 10 000 files and 5 000 changes a commit takes 3.8 s and a clean status 3.0 s, because every snapshot loads and materializes the whole base state (about 75 ms load, 75 ms materialize, 50 ms walk at 4 000 files). Skip load and materialize when every file's stat matches a manifest written for the same base; make the base state lazy for callers that need it; cache the materialized snapshot per state. Target the spec §25 numbers. |
|
| 47 | 48 | - [ ] O5 Concurrency: two processes committing, snapshotting and pushing on one repository; locks hold, nothing is lost. |
|
| 48 | 49 | - [ ] O6 Error messages audit: every user-facing error says what to do next; `oxy doctor` checks identity, config, remotes, CA files, hub reachability. |
|
| 49 | 50 | - [ ] O7 Shell completions (`oxy completions bash|zsh|fish`) and man pages generated from clap. |
|
| … | 4 unchanged lines |