OXYGENHUB
# d443639bcf35 oxygen / docs / benchmarks.md
7.3 KiB · Markdown · last changed Faster state loading: seeded fast hashing for the file graphs, no unused predecessor map, inline successor sets, lines moved rather than copied; OXY_TIMINGS=1 prints snapshot phase timings. Clean status at 10 000 files 0.96 s to 0.52 s, memory 657 to 239 MB 1 month ago renderedsource blame history raw

Benchmarks

The harness is crates/oxy-core/tests/bench.rs (spec §25). It builds a repository through the library, one commit at a time, then times the everyday operations against thresholds:

OXY_BENCH_DIR=~/.cache/oxy-bench \
  cargo test --release -p oxy-core --test bench -- --ignored --nocapture

OXY_BENCH_FILES (default 10 000) and OXY_BENCH_CHANGES (default 5 000) set the size. The data lands under OXY_BENCH_DIR; /tmp is often a RAM disk, which hides the cost of fsync, so measure on a real disk.

2026-09-28: first run

Machine: 8 cores, x86-64, NVMe (ext4 on LVM), release build, warm cache. Files are 21 lines each in 100 directories; one change in ten appends a line to hot.txt, the rest rewrite one ordinary file.

Operation (10 000 files, 5 000 changes)TimeThreshold
commit: import 10 000 files1 031 ms60 000 msok
commit: one file (mean of 4 999)3 761 ms250 msover
status: clean working copy (stat crawl)2 977 ms2 000 msover
status: 10 files modified3 062 ms2 000 msover
commit: 10 files9 089 ms500 msover
log: whole bookmark408 ms2 000 msok
blame: hot file (500 lines)2 920 ms2 000 msover
blame: ordinary file2 927 ms1 000 msover
checkpoint596 ms10 000 msok
status after checkpoint2 873 ms2 000 msover
push: everything to an empty remote782 ms60 000 msok
push: up to date4 ms1 000 msok
clone: everything, with checkout3 378 ms60 000 msok
fsck7 955 ms60 000 msok
gc9 489 ms60 000 msok
status after gc2 829 ms2 000 msover

Peak resident memory over the whole run: 1 405 MiB. The run took 5 h 14 min, almost all of it building the history, because each commit costs as much as a status.

Smaller runs, same machine:

Files, changesMean commitClean statusBlame (hot)Peak memory
1 000, 20090 ms62 ms62 ms70 MiB
1 000, 800166 ms130 ms119 ms118 MiB
4 000, 200343 ms235 ms239 ms254 MiB
10 000, 5 0003 761 ms2 977 ms2 920 ms1 405 MiB

What it shows

Sync, log, checkpoints and repository maintenance are fine. Everything that snapshots the working copy (status, commit, blame of the working copy's base) grows with both the number of files and the length of the history. Timing the phases of one snapshot at 4 000 files and 300 changes:

PhaseTime
load the base state (checkpoint plus the changes since)75–130 ms
materialize every file of the base state75 ms
walk the working copy against the manifest50 ms
build the working-copy change8 ms

A clean status should be a stat walk and nothing else. The fix is backlog item O10: skip loading and materializing when every file's stat matches a manifest written for the same base, make the base state lazy for the callers that need it, and cache materialized snapshots per state. The thresholds above stay as they are until then, so the harness reports the gap rather than hiding it.

2026-09-28: after O10a

State::maximal compared every pair of heads, and the benchmark's bookmark has about 4 500 of them (changes to different files are independent). It now walks once; commit also stops loading the base state twice. Same machine and size:

Operation (10 000 files, 5 000 changes)BeforeAfterThreshold
commit: one file (mean of 4 999)3 761 ms861 ms250 msover
status: clean working copy2 977 ms803 ms2 000 msok
status: 10 files modified3 062 ms864 ms2 000 msok
commit: 10 files9 089 ms861 ms500 msover
log: whole bookmark408 ms400 ms2 000 msok
blame: hot file2 920 ms809 ms2 000 msok
blame: ordinary file2 927 ms828 ms1 000 msok
status after checkpoint2 873 ms770 ms2 000 msok
push: everything782 ms742 ms60 000 msok
clone: everything3 378 ms1 664 ms60 000 msok
fsck7 955 ms6 872 ms60 000 msok
gc9 489 ms9 526 ms60 000 msok

Peak resident memory: 1 145 MiB (was 1 405). The run took 1 h 12 min (was 5 h 14 min). Commit time no longer grows with history; what remains grows with the number of files: at 10 000 files a snapshot spends about 320 ms loading the base state, 190 ms materializing it, 130 ms walking the working copy and 40 ms building the change. That is O10b.

2026-09-28: after O10b

Rendering the base state, building file graphs from a checkpoint and reading the working copy now run on all cores. Measured at 10 000 files with 60 and 300 changes (commit time no longer depends on the length of history, so the full 5 000 was not rerun):

Phase of one snapshot, 10 000 filesAfter O10aAfter O10b
load the base state320 ms180–280 ms
materialize the base state190 ms75 ms
walk and read the working copy130 ms80 ms
build the working-copy change40 ms40 ms
write the manifest–46 ms
Operation, 10 000 filesAfter O10aAfter O10bThreshold
status: clean working copy803 ms464–581 ms2 000 ms
commit: one file (mean)861 ms550–605 ms250 ms
commit: 10 files861 ms583–635 ms500 ms

Loading the whole base state is now the largest cost. O10c removes it: a snapshot should load and render only the files whose stat changed, which needs checkpoints that can be read per file.

2026-09-28: O10c, first part: the in-memory state

Profiling a clean snapshot with OXY_TIMINGS=1 (10 000 files of 40 lines, 200 changes) showed loading the base state dominating it. Applying the changes to the in-memory graphs cost 650 ms and reading them 160 ms. A checkpoint did not help: decoding one took 680 ms, as long as replaying every change, because both build the same graphs for 400 000 lines. So the cost was in the graph structure, not in the format:

  • Hashing. The graph maps used SipHash over 36-byte keys, three to five times per line. The keys begin with BLAKE3 hashes, so they now use a seeded multiply-rotate hasher (fasthash.rs). The seed is random per process, so keys chosen by someone else cannot be aimed at one bucket.
  • The predecessor map. It was written for every line and read nowhere, so it is gone.
  • Successor sets. A BTreeSet per vertex became a sorted edge list that keeps its single successor inline. Nearly every line has one.
  • Line bytes. Loading moves them into the graph instead of copying them, and chains each new line with one lookup instead of three.

Both binaries were built from the same tree and run alternately, nine times each, medians shown:

10 000 files, 200 changesBeforeAfter
status: clean working copy0.96 s0.52 s
commit: one file0.99 s0.51 s
peak memory (either)657 MB239 MB

The checkpoint format is unchanged. What remains of O10c is split into O10d and O10e in the backlog:

  • O10d. Skip reading text files whose stat matches the manifest, when the manifest recorded them equal to the base.
  • O10e. Checkpoints readable per file, so a snapshot loads only the graphs it needs. This one changes the checkpoint format in spec §25.