| 1d360c91 Andy McCall ↶ |
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. |
| b8a03993 Andy McCall ↶ |
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. |
| 0bb9413f Andy McCall ↶ |
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. |
| 0bdc3f91 Andy McCall ↶ |
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. |
| cc7a0056 Andy McCall ↶ |
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. |
| 8ab265dc Andy McCall ↶ |
196 |
|
| 197 |
## 2026-09-29: O10h, commit, diff and show build only what they touch |
| 198 |
|
| 199 |
Commit, working-copy diff, hunks and discard use the partial snapshot of |
| 200 |
O10g. Files known unchanged are neither read nor rendered, and only the |
| 201 |
graphs of the files the working copy changes are built. Showing a |
| 202 |
committed change (`oxy show`, `oxy diff CHANGE`) builds the graphs of the |
| 203 |
files that change edits and leaves the rest unrendered. |
| 204 |
|
| 205 |
Same repository as O10g (10 000 files of 40 lines): |
| 206 |
|
| 207 |
|
| 208 |
|
| 209 |
| diff (working copy) | 0.57 s, 244 MB | 0.22 s, 52 MB | |
| 210 |
| commit (one file) | 0.58 s, 245 MB | 0.23 s, 52 MB | |
| 211 |
| show (a one-file change) | not measured | 0.53 s, 145 MB | |
| 212 |
|
| 213 |
Caching materialized states, planned alongside, is not needed now that |
| 214 |
these commands render almost nothing. |
| 958fb27d Andy McCall ↶ |
215 |
|
| 216 |
## 2026-09-29: P5c, importing microsoft/vscode, first part |
| 217 |
|
| 218 |
`oxy git import` on slices of microsoft/vscode's `main` (166 412 commits in |
| 219 |
total), taken in the importer's own order (`git log --reverse |
| 220 |
--topo-order`). The first 5 000 commits reach May 2016 with about 4 700 |
| 221 |
files. |
| 222 |
|
| 223 |
The importer did work proportional to the whole tree for every commit: |
| 224 |
- It cloned the previous state. |
| 225 |
- It rebuilt a merge's first parent from history. |
| 226 |
- It rendered, read and compared every file. |
| 227 |
- It scanned every entry, with an allocation each, to decide whether each |
| 228 |
directory was still needed. |
| 229 |
|
| 230 |
Now: |
| 231 |
- Files with the same Git blob and mode as in the first parent's tree are |
| 232 |
marked unchanged and skipped, as a clean working copy's are. Paths that a |
| 233 |
merge's other parents touched are excluded. |
| 234 |
- A commit's state is kept while first-parent children remain, up to 8, |
| 235 |
and the last child takes it without a clone. |
| 236 |
- Needed directories come from one set of ancestors. That code is shared |
| 237 |
by snapshot and commit. |
| 238 |
|
| 239 |
|
| 240 |
|
| 241 |
| 300 commits (35 merges) | 65.1 s | 13.6 s | |
| 242 |
| 1 000 commits (95 merges) | 271 s | 120 s, 3.3 GB | |
| 243 |
| 5 000 commits (360 merges) | not finished after 80 min | 412 s, 6.3 GB, 17 MB store | |
| 244 |
|
| 245 |
Where the 5 000-commit import spends its time (summed per phase): render |
| 246 |
base 172 s, build change 99 s, bases 65 s, `git ls-tree` 56 s, read blobs |
| 247 |
10 s, store 5 s. |
| 248 |
|
| 249 |
Correctness: `oxy git verify` reproduces every commit of the 300 and |
| 250 |
1 000-commit imports (1.9 million file comparisons for 1 000). A test |
| 251 |
covers the case that must not be skipped: a `-s ours` merge whose other |
| 252 |
side edited a file. |
| 253 |
|
| 254 |
Next: rendering the base is still O(files) per commit, and memory grows |
| 255 |
with the kept states. The full import follows. |
| 2951f802 Andy McCall ↶ |
256 |
|
| 257 |
## Linux, stage 1: the tip as one commit (2026-09-29) |
| 258 |
|
| 259 |
The Linux tree at its tip: 95 943 files, 1.8 GB, on Earth (4 cores, |
| 260 |
31 GiB), each run capped at 14 GB. |
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
| First commit | 33.7 s, 42 MB | failed, 10.9 GB | 55.3 s, 9.9 GB | |
| 265 |
| Store | | unreadable | 442 MB | |
| 266 |
| Clean `status` | 0.26 to 0.31 s | | killed at 14 GB | |
| 267 |
|
| 268 |
Before format 2 the commit made one change of 1.8 GB. That was over the |
| 269 |
1 GiB an object may be, and the repository would not open afterwards |
| 270 |
(oxygen#33, oxygen#34). Format 2 stores each insert of 4 KiB or more as |
| 271 |
a text blob, so the change stays small. |
| 272 |
|
| 273 |
Memory is the next problem. Every line is kept as its own allocation, |
| 274 |
once in the change and again in the file graph, and `status` builds |
| 275 |
every graph (L1b). |
| 276 |
|
| 86e1824f Andy McCall ↶ |
277 |
### Status after the commit (2026-09-29, oxygen#35) |
| 278 |
|
| 279 |
A profile with the counting allocator (`--features alloc-stats`, |
| 280 |
`OXY_TIMINGS=1`) of the first clean `status`, before it was killed: |
| 281 |
|
| 282 |
|
| 283 |
|
| 284 |
| Load base history | 17.5 s | 3.2 GB | 16 GB in 96 M allocations | |
| 285 |
| Read working copy | 79.5 s | 4.9 GB | 1.8 GB | |
| 286 |
| Load and render base | killed at 14 GB | | | |
| 287 |
|
| 288 |
It had two causes, and both are fixed: |
| 289 |
- **History held every line.** A history-only state now reads a stored |
| 290 |
insert's text blob only when it builds that file's graph, and a load no |
| 291 |
longer hashes each change again, which for format 2 means chunking all |
| 292 |
its text. |
| 293 |
- **The manifest had no graph keys.** They are now recorded at commit, so |
| 294 |
the next status neither reads nor renders an unchanged file. |
| 295 |
|
| 296 |
|
| 297 |
|
| 298 |
| Before | killed at 14 GB | | |
| 299 |
| After | 3.7 s | 768 MB | |
| 300 |
| Git | 0.3 s | 31 MB | |
| 301 |
|
| 302 |
Still to do: status to the 0.4 s and 300 MB target; `fsck` is killed at |
| 303 |
14 GB; the first commit takes 57 s and 10.2 GB. |
| 304 |
|
| c5ce0367 Andy McCall ↶ |
305 |
### Checkout and clone (2026-09-29, oxygen#35) |
| 306 |
|
| 307 |
A checkout built and rendered every file of the target, read the whole |
| 308 |
working copy, and after writing walked and hashed it all again for the |
| 309 |
manifest. Switching bookmarks or cloning the Linux tree was killed at |
| 310 |
14 GB. Now: |
| 311 |
- a file is skipped when the manifest shows it unchanged and its graph is |
| 312 |
the same in both states; |
| 313 |
- the rest are rendered 2 048 at a time, and each batch's graphs are |
| 314 |
dropped once written; |
| 315 |
- the manifest is built from what the checkout already knows. |
| 316 |
|
| 317 |
|
| 318 |
|
| 319 |
| Switch bookmark (one file differs) | killed at 14 GB | 7.1 to 7.4 s, 1.3 GB | 0.18 to 0.22 s, 39 MB | |
| 320 |
| Local clone | killed at 14 GB | 98 s, 7.9 GB | 9.8 s, 469 MB | |
| 321 |
|
| 322 |
Most of a switch is now loading history: the base's and the target's |
| 323 |
history each decode the one huge first change (1.2 s and 364 MB each). |
| 324 |
A clone holds the whole bundle in memory. |
| 325 |
|
| 69816d68 Andy McCall ↶ |
326 |
### log, blame and fsck (2026-09-30, oxygen#35) |
| 327 |
|
| 328 |
The first full run of `~/bench/linux-bench.sh` against this work found |
| 329 |
`log -n 100` taking 28 s and 4.7 GB, and `blame` killed at 14 GB: |
| 330 |
- **log.** Checking each change's signature read the whole change, and |
| 331 |
for the first change that meant all 1.8 GB of its text. Signature |
| 332 |
checks, the dependency walks of fetch and push, and the prompt now |
| 333 |
read a change's metadata from its skeleton, and log answers the |
| 334 |
working copy's state from the manifest when it proves it clean. |
| 335 |
- **blame.** It took a full snapshot, building every graph, to blame one |
| 336 |
file; it now loads the base's history and builds that file's graph. |
| 337 |
- **fsck.** A bookmark's state was checked by loading it whole. It is |
| 338 |
now checked a batch of graphs at a time, deleted files' graphs |
| 339 |
included. |
| 340 |
|
| 341 |
|
| 342 |
|
| 343 |
| `log -n 100` | 28 s, 4.7 GB | 2.0 s, 435 MB | 0.16 s | |
| 344 |
| `blame MAINTAINERS` | killed at 14 GB | 1.1 s, 315 MB | 0.31 s | |
| 345 |
| `fsck` | killed at 14 GB | 123 s, 4.5 GB | to measure again | |
| 346 |
|
| 8b276e01 Andy McCall ↶ |
347 |
### One file changed (2026-09-30, oxygen#35) |
| 348 |
|
| 349 |
A status with one file changed (MAINTAINERS) took 4.1 s: the walk ran |
| 350 |
on one thread (0.83 s), and the manifest was written by statting all |
| 351 |
96 000 files again (0.57 s). The walk now runs in parallel and each |
| 352 |
file's stat is carried from the walk into the manifest. It now takes |
| 353 |
2.5 s: the base's history 0.5 s, its tree view and graph keys 0.38 s, |
| 354 |
the walk 0.35 s, rendering the changed file 0.47 s, the diff 0.18 s, |
| 355 |
and the manifest 0.14 s. (Git: 0.23 s.) |
| 356 |
|