| 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 |
|
| 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 |
|
| 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 |
|
| 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. |
| 72 |
|
| 73 |
## 2026-09-28: after O10a |
| 74 |
|
| 75 |
`State::maximal` compared every pair of heads, and the benchmark's |
| 76 |
bookmark has about 4 500 of them (changes to different files are |
| 77 |
independent). It now walks once; commit also stops loading the base state |
| 78 |
twice. Same machine and size: |
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
| commit: one file (mean of 4 999) | 3 761 ms | 861 ms | 250 ms | **over** | |
| 83 |
| status: clean working copy | 2 977 ms | 803 ms | 2 000 ms | ok | |
| 84 |
| status: 10 files modified | 3 062 ms | 864 ms | 2 000 ms | ok | |
| 85 |
| commit: 10 files | 9 089 ms | 861 ms | 500 ms | **over** | |
| 86 |
| log: whole bookmark | 408 ms | 400 ms | 2 000 ms | ok | |
| 87 |
| blame: hot file | 2 920 ms | 809 ms | 2 000 ms | ok | |
| 88 |
| blame: ordinary file | 2 927 ms | 828 ms | 1 000 ms | ok | |
| 89 |
| status after checkpoint | 2 873 ms | 770 ms | 2 000 ms | ok | |
| 90 |
| push: everything | 782 ms | 742 ms | 60 000 ms | ok | |
| 91 |
| clone: everything | 3 378 ms | 1 664 ms | 60 000 ms | ok | |
| 92 |
| fsck | 7 955 ms | 6 872 ms | 60 000 ms | ok | |
| 93 |
| gc | 9 489 ms | 9 526 ms | 60 000 ms | ok | |
| 94 |
|
| 95 |
Peak resident memory: 1 145 MiB (was 1 405). The run took 1 h 12 min |
| 96 |
(was 5 h 14 min). Commit time no longer grows with history; what remains |
| 97 |
grows with the number of files: at 10 000 files a snapshot spends about |
| 98 |
320 ms loading the base state, 190 ms materializing it, 130 ms walking the |
| 99 |
working copy and 40 ms building the change. That is O10b. |
| 100 |
|
| 101 |
## 2026-09-28: after O10b |
| 102 |
|
| 103 |
Rendering the base state, building file graphs from a checkpoint and |
| 104 |
reading the working copy now run on all cores. Measured at 10 000 files |
| 105 |
with 60 and 300 changes (commit time no longer depends on the length of |
| 106 |
history, so the full 5 000 was not rerun): |
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
| load the base state | 320 ms | 180–280 ms | |
| 111 |
| materialize the base state | 190 ms | 75 ms | |
| 112 |
| walk and read the working copy | 130 ms | 80 ms | |
| 113 |
| build the working-copy change | 40 ms | 40 ms | |
| 114 |
| write the manifest | – | 46 ms | |
| 115 |
|
| 116 |
|
| 117 |
|
| 118 |
| status: clean working copy | 803 ms | 464–581 ms | 2 000 ms | |
| 119 |
| commit: one file (mean) | 861 ms | 550–605 ms | 250 ms | |
| 120 |
| commit: 10 files | 861 ms | 583–635 ms | 500 ms | |
| 121 |
|
| 122 |
Loading the whole base state is now the largest cost. O10c removes it: a |
| 123 |
snapshot should load and render only the files whose stat changed, which |
| 124 |
needs checkpoints that can be read per file. |
| 125 |
|
| 126 |
## 2026-09-28: O10c, first part: the in-memory state |
| 127 |
|
| 128 |
Profiling a clean snapshot with `OXY_TIMINGS=1` (10 000 files of 40 lines, |
| 129 |
200 changes) showed loading the base state dominating it. Applying the |
| 130 |
changes to the in-memory graphs cost 650 ms and reading them 160 ms. A |
| 131 |
checkpoint did not help: decoding one took 680 ms, as long as replaying |
| 132 |
every change, because both build the same graphs for 400 000 lines. So the |
| 133 |
cost was in the graph structure, not in the format: |
| 134 |
|
| 135 |
- **Hashing.** The graph maps used SipHash over 36-byte keys, three to |
| 136 |
five times per line. The keys begin with BLAKE3 hashes, so they now use |
| 137 |
a seeded multiply-rotate hasher (`fasthash.rs`). The seed is random per |
| 138 |
process, so keys chosen by someone else cannot be aimed at one bucket. |
| 139 |
- **The predecessor map.** It was written for every line and read nowhere, |
| 140 |
so it is gone. |
| 141 |
- **Successor sets.** A `BTreeSet` per vertex became a sorted edge list |
| 142 |
that keeps its single successor inline. Nearly every line has one. |
| 143 |
- **Line bytes.** Loading moves them into the graph instead of copying |
| 144 |
them, and chains each new line with one lookup instead of three. |
| 145 |
|
| 146 |
Both binaries were built from the same tree and run alternately, nine |
| 147 |
times each, medians shown: |
| 148 |
|
| 149 |
|
| 150 |
|
| 151 |
| status: clean working copy | 0.96 s | 0.52 s | |
| 152 |
| commit: one file | 0.99 s | 0.51 s | |
| 153 |
| peak memory (either) | 657 MB | 239 MB | |
| 154 |
|
| 155 |
The checkpoint format is unchanged. What remains of O10c is split into |
| 156 |
O10d and O10e in the backlog: |
| 157 |
|
| 158 |
- **O10d.** Skip reading text files whose stat matches the manifest, when |
| 159 |
the manifest recorded them equal to the base. |
| 160 |
- **O10e.** Checkpoints readable per file, so a snapshot loads only the |
| 161 |
graphs it needs. This one changes the checkpoint format in spec §25. |
| 162 |
|
| 163 |
## 2026-09-28: O10g, first part: status snapshots skip unchanged files |
| 164 |
|
| 165 |
The manifest now records, per text file equal to the base, the key of the |
| 166 |
base graph it matched: the file's node, its checkpoint graph (blob, |
| 167 |
inline value or none) and the set of later changes that edited it |
| 168 |
(spec §12.4). When a file's stat and key both still match, the snapshot |
| 169 |
neither reads nor renders it. |
| 170 |
|
| 171 |
`status`, `log`, `show`, `tag` and `bookmark` use a partial snapshot. It |
| 172 |
loads the base for its history and builds only the graphs of files that |
| 173 |
are not known unchanged. `commit`, `diff` and the rest still build the |
| 174 |
whole base. |
| 175 |
|
| 176 |
10 000 files of 40 lines, 22 changes, a version 2 checkpoint after 21 of |
| 177 |
them; `OXY_TIMINGS=1`, clean working copy: |
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
| status | 0.55 s | 0.22 s | |
| 182 |
| status peak memory | 259 MB | 51 MB | |
| 183 |
| log | 0.7–0.8 s | 0.38 s | |
| 184 |
| diff (full snapshot) | 0.56 s | 0.57 s | |
| 185 |
|
| 186 |
|
| 187 |
|
| 188 |
| load base (whole / history only) | 317 ms | 31 ms | |
| 189 |
| render base | 91 ms | 25 ms | |
| 190 |
| read working copy | 68 ms | 50 ms | |
| 191 |
| diff against base | 41 ms | 28 ms | |
| 192 |
| write manifest | 33 ms | 35 ms | |
| 193 |
|
| 194 |
The same for `commit` and `diff` comes next (O10h). There, only the graphs |
| 195 |
the change touches are needed, and materialized states can be cached. |