OXYGENHUB

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