| 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 | | |
| da868d5d Andy McCall ↶ |
299 |
| Lazy text and keys at commit | 3.7 s | 768 MB | |
| 300 |
| And answered from the manifest | 0.72 s | 120 MB | |
| eaf3e906 Andy McCall ↶ |
301 |
| And the manifest checked by a parallel walk | 0.44 s | 123 MB | |
| da868d5d Andy McCall ↶ |
302 |
| Git | 0.22 s | 31 MB | |
| 303 |
|
| 304 |
A clean status is now answered from the manifest alone, loading no |
| 305 |
history. The manifest also records the executable bit, since a mode |
| 306 |
change alters neither size nor time. Any change, a manifest older than |
| 307 |
the racy window, or subrepos send it back to a full snapshot. |
| 86e1824f Andy McCall ↶ |
308 |
|
| da868d5d Andy McCall ↶ |
309 |
Still to do: status to the 0.4 s target (the walk and reading the |
| 310 |
manifest); a status without a usable manifest still builds every graph |
| 311 |
and is killed at 14 GB; `fsck` is killed at |
| 86e1824f Andy McCall ↶ |
312 |
14 GB; the first commit takes 57 s and 10.2 GB. |
| 313 |
|
| c5ce0367 Andy McCall ↶ |
314 |
### Checkout and clone (2026-09-29, oxygen#35) |
| 315 |
|
| 316 |
A checkout built and rendered every file of the target, read the whole |
| 317 |
working copy, and after writing walked and hashed it all again for the |
| 318 |
manifest. Switching bookmarks or cloning the Linux tree was killed at |
| 319 |
14 GB. Now: |
| 320 |
- a file is skipped when the manifest shows it unchanged and its graph is |
| 321 |
the same in both states; |
| 322 |
- the rest are rendered 2 048 at a time, and each batch's graphs are |
| 323 |
dropped once written; |
| 324 |
- the manifest is built from what the checkout already knows. |
| 325 |
|
| 326 |
|
| 327 |
|
| 328 |
| 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 | |
| 329 |
| Local clone | killed at 14 GB | 98 s, 7.9 GB | 9.8 s, 469 MB | |
| 330 |
|
| a4fad5b0 Andy McCall ↶ |
331 |
A history-only load now reads a change's skeleton: the inline lines of |
| 332 |
its inserts are stepped over, not decoded, and are read from the change |
| 333 |
only for a graph that is built. A one-file switch then takes 5.4 to 5.6 s |
| 334 |
and 0.8 GB, and the status after it 3.3 s and 600 MB (from 3.8 s). |
| 335 |
|
| d3cd7ef9 Andy McCall ↶ |
336 |
A checkout of a working copy that is clean by the manifest now skips the |
| 337 |
status snapshot, and a manifest entry inside the racy window is checked |
| 338 |
by hashing the file rather than distrusting the whole manifest. A |
| 339 |
one-file switch then takes 3.7 to 3.9 s and 705 MB. Where it goes: |
| 340 |
checking the manifest (a walk of 96 000 files) 0.75 s, the base's and |
| 341 |
the target's history 0.5 s each, their tree views 0.3 s each, planning |
| 342 |
0.2 s, and writing the manifest 0.6 s. |
| 343 |
|
| 0554aaa7 Andy McCall ↶ |
344 |
A checkout now needs no base state at all: after the manifest check or a |
| 345 |
snapshot, the manifest says which graph each file on disk renders. A |
| 346 |
file is skipped when its manifest key is the target's, and a file left |
| 347 |
as it was keeps its stat in the new manifest instead of being statted |
| 348 |
again. What the new state lacks is found by a walk from the old heads |
| 349 |
that stops at the target. A one-file switch takes 2.1 to 2.2 s and 455 |
| 350 |
MB: the manifest check 0.4 s, the target's history 0.5 s and tree view |
| 351 |
0.3 s, planning 0.16 s, and writing the manifest 0.4 s. |
| 352 |
|
| a4fad5b0 Andy McCall ↶ |
353 |
Before that, most of a switch was loading history: the base's and the target's |
| c5ce0367 Andy McCall ↶ |
354 |
history each decode the one huge first change (1.2 s and 364 MB each). |
| 355 |
A clone holds the whole bundle in memory. |
| 356 |
|
| 69816d68 Andy McCall ↶ |
357 |
### log, blame and fsck (2026-09-30, oxygen#35) |
| 358 |
|
| 359 |
The first full run of `~/bench/linux-bench.sh` against this work found |
| 360 |
`log -n 100` taking 28 s and 4.7 GB, and `blame` killed at 14 GB: |
| 361 |
- **log.** Checking each change's signature read the whole change, and |
| 362 |
for the first change that meant all 1.8 GB of its text. Signature |
| 363 |
checks, the dependency walks of fetch and push, and the prompt now |
| 364 |
read a change's metadata from its skeleton, and log answers the |
| 365 |
working copy's state from the manifest when it proves it clean. |
| 366 |
- **blame.** It took a full snapshot, building every graph, to blame one |
| 367 |
file; it now loads the base's history and builds that file's graph. |
| 368 |
- **fsck.** A bookmark's state was checked by loading it whole. It is |
| 369 |
now checked a batch of graphs at a time, deleted files' graphs |
| 370 |
included. |
| 371 |
|
| 372 |
|
| 373 |
|
| 374 |
| `log -n 100` | 28 s, 4.7 GB | 2.0 s, 435 MB | 0.16 s | |
| 375 |
| `blame MAINTAINERS` | killed at 14 GB | 1.1 s, 315 MB | 0.31 s | |
| 376 |
| `fsck` | killed at 14 GB | 123 s, 4.5 GB | to measure again | |
| 377 |
|
| 8b276e01 Andy McCall ↶ |
378 |
### One file changed (2026-09-30, oxygen#35) |
| 379 |
|
| 380 |
A status with one file changed (MAINTAINERS) took 4.1 s: the walk ran |
| 381 |
on one thread (0.83 s), and the manifest was written by statting all |
| 382 |
96 000 files again (0.57 s). The walk now runs in parallel and each |
| 383 |
file's stat is carried from the walk into the manifest. It now takes |
| 384 |
2.5 s: the base's history 0.5 s, its tree view and graph keys 0.38 s, |
| 385 |
the walk 0.35 s, rendering the changed file 0.47 s, the diff 0.18 s, |
| 206c76ca Andy McCall ↶ |
386 |
and the manifest 0.14 s. (Git: 0.23 s.) The tree view is now computed once per |
| 387 |
state and cloned for each use, which brings rendering to 0.2 s and the |
| 388 |
status to 2.3 s. |
| 8b276e01 Andy McCall ↶ |
389 |
|
| 192fad1e Andy McCall ↶ |
390 |
### The first commit (2026-09-30, oxygen#35) |
| 391 |
|
| 392 |
A profile of the first commit of the Linux tip (61 s, 10 GB RSS): read |
| 393 |
the working copy 2.3 s (1.6 GB of content), diff against the empty base |
| 394 |
6.2 s (4.2 GB live, a Vec per line), hash and record 18.8 s, then the |
| 395 |
commit itself about 33 s. The text of the one huge change was chunked |
| 396 |
and hashed three or four times on one thread (hashing the working-copy |
| 397 |
change, storing it, storing the commit, adding it to the state), the |
| 398 |
commit copied every line of the working copy, and the chunks were |
| 399 |
compressed on one thread. |
| 400 |
|
| 401 |
Now: |
| 402 |
- a snapshot encodes the working copy once, and hashes and stores that |
| 403 |
one encoding; |
| 404 |
- a whole commit moves the working copy's lines rather than copying |
| 405 |
them, and joins the state under the hash it was stored by; |
| 406 |
- text blobs are made on every core, 256 inserts at a time; |
| 407 |
- the chunks of a large change are compressed on every core |
| 408 |
(`Store::put_many`). |
| 409 |
|
| 410 |
|
| 411 |
|
| 412 |
| Before | 61 s | 7.1 GB | 10.1 GB | |
| 413 |
| After | 40.7 s | 7.4 GB | 8.7 GB | |
| 17f94b13 Andy McCall ↶ |
414 |
| Texts stored a batch at a time while encoding | 41.6 s | 4.8 GB | 5.8 GB | |
| 01a47cd7 Andy McCall ↶ |
415 |
| Lines in one shared buffer per insert (Earth under load) | 50.1 s | 4.3 GB | 4.9 GB | |
| 192fad1e Andy McCall ↶ |
416 |
| Git | 33.7 to 37.9 s | | 42 MB | |
| 417 |
|
| 17f94b13 Andy McCall ↶ |
418 |
Storing a change's texts a batch at a time as they are made (the |
| 419 |
encoder hands each batch of 256 to the store) removed the storing peak. |
| 01a47cd7 Andy McCall ↶ |
420 |
The peak was then the working copy's content (1.6 GB) and its lines, a |
| 421 |
Vec each (2.6 GB). An insert's lines are now one buffer with line ends |
| 422 |
(`Lines`), shared by the change and by the file graph, whose atoms point |
| 423 |
into it: the diff of the whole tree takes 3.3 s and 1.1 million |
| 424 |
allocations (from 6.2 s and 45 million), and the peak is 4.3 GB (4.9 GB |
| 425 |
RSS). That run shared Earth with Flow jobs (load average 6.5 on 4 cores), |
| 426 |
so its total time is not comparable with the rows above. |
| 192fad1e Andy McCall ↶ |
427 |
|
| 598009a7 Andy McCall ↶ |
428 |
### Clone (2026-09-30, oxygen#35) |
| 429 |
|
| 430 |
A local clone of the Linux tip took 96 s and 5.2 GB RSS. Its checkout |
| 431 |
spent most of its time rendering: working out each file's line order |
| 432 |
allocated three sets per line (the contraction of the graph), 93 GB in |
| 433 |
all, although every file of a fresh tree is one chain of lines. And the |
| 434 |
import hashed each change three times, for the huge first change |
| 435 |
chunking its 1.8 GB of text each time. |
| 436 |
|
| 437 |
- A graph that is one chain from start to end with nothing deleted (an |
| 438 |
edge to End besides the next line orders nothing) is read off in |
| 439 |
order; any other graph takes the general algorithm, which also skips |
| 440 |
its contraction where a line's successors are all kept. A test checks |
| 441 |
the two agree. |
| 442 |
- A bundle carries each change's hash when its maker knows it (the store |
| 443 |
it came from, or the received bytes); never from the wire. An import |
| 444 |
hashes each change once, by storing it, and checks it is the named one. |
| 445 |
|
| 446 |
|
| 447 |
|
| 448 |
| Before | 96 s (81.6 s in a quieter run) | 5.2 to 5.4 GB | |
| 449 |
| After | 61.0 s | 4.1 GB | |
| 519c7c06 Andy McCall ↶ |
450 |
| The change read once for all batches | 56.8 s | 4.7 GB | |
| 2c75672f Andy McCall ↶ |
451 |
| Changes moved as stored (load average 5.2) | 48.2 s | 3.9 GB | |
| 598009a7 Andy McCall ↶ |
452 |
| Git | 9.6 s | 469 MB | |
| 453 |
|
| 2c75672f Andy McCall ↶ |
454 |
A fetch between repositories on one machine now moves changes as they |
| 455 |
are stored, their text blobs and chunks among the objects, instead of |
| 456 |
decoding every change's text on one side and chunking, hashing and |
| 457 |
compressing it again on the other; the import stores all the objects at |
| 458 |
once, compressed on every core, and refuses a change whose text blob did |
| 459 |
not come. |
| 598009a7 Andy McCall ↶ |
460 |
|
| a87c7b9d Andy McCall ↶ |
461 |
### Git import: the vscode slice (2026-09-30, oxygen#30) |
| 462 |
|
| 463 |
The 1 747-commit vscode slice (to merge 0babb4299131) had one file as a |
| 464 |
name conflict at every commit from that merge on: 51 verify differences. |
| 465 |
There were three causes, all from Git having no identity for a directory: |
| 466 |
- a directory deleted on one line and made again got a second node for |
| 467 |
the same path; |
| 468 |
- a placement left under a directory another line removed was out of |
| 469 |
sight, so no merge could retire it; |
| 470 |
- a file whose only placements were under removed directories (an |
| 471 |
orphan) could not be seen either. |
| 472 |
|
| 473 |
Now an import brings a directory back rather than making a second node, |
| 474 |
and a merge retires a kept node's other placements and every orphan. |
| 475 |
|
| 476 |
`oxy git verify` built and rendered every file of every commit and read |
| 477 |
every Git blob; it now loads each commit's history and builds only the |
| 478 |
files whose graph (by key) or blob it has not already found equal to |
| 479 |
that Git blob. |
| 480 |
|
| 481 |
|
| 482 |
|
| 483 |
| Import | 128.6 s | 132.1 s | |
| 484 |
| Differences | 51 | none: 3 327 291 files compared | |
| 485 |
| Verify | 1 369 to 1 444 s, 832 MB | 114.6 s, 441 MB | |
| 486 |
|
| b493d993 Andy McCall ↶ |
487 |
### Git import memory (2026-09-30, backlog H2) |
| 488 |
|
| 489 |
An import kept up to eight full states (every file's graph) for commits |
| 490 |
whose children were still to come. It now keeps history-only states and |
| 491 |
builds the graphs of only the files a commit may change; unchanged ones |
| 492 |
were already neither read nor rendered. An imported change joins its |
| 493 |
base under the hash it was stored by, instead of being encoded again. |
| 494 |
|
| 495 |
|
| 496 |
|
| 497 |
| Before (2026-09-29) | 412 s | 6.3 GB | | |
| 498 |
| After | 309 s | 2.3 GB | every commit (13.4 million files) in 978 s | |
| 499 |
|
| 57d8dc05 Andy McCall ↶ |
500 |
Verify loaded each commit's history from scratch. It now keeps the |
| 501 |
state of a commit whose children are still to come, as the import does, |
| 502 |
and a child adds only the changes its parent lacks. Those states stay |
| 503 |
history only: graphs are built beside them (`State::build_graphs_detached`) |
| 504 |
and dropped after rendering, so changes are added without their text and |
| 505 |
every file keeps its graph key, which the cache of verified files needs. |
| 506 |
The same 5 000 commits verify in 166.5 s (from 978 s), 1.3 GB. |
| b493d993 Andy McCall ↶ |
507 |
|
| 60622d23 Andy McCall ↶ |
508 |
### Git import at scale (2026-09-30, oxygen#25) |
| 509 |
|
| 510 |
An imported commit cost what the whole tree is: `git ls-tree` of every |
| 511 |
path, a tree view worked out again, every path compared, and every |
| 512 |
changed file rendered from its whole history. Phases timed over the |
| 513 |
first 5 000 vscode commits (`OXY_TIMINGS`, repeated phases summed) led |
| 514 |
the work, one cost at a time: |
| 515 |
|
| 516 |
|
| 517 |
|
| 518 |
| Before | 312 s | |
| 519 |
| Each commit's tree is its parent's plus the diff, from one `git diff-tree --stdin` | 245 s | |
| 520 |
| A parent not kept has its tree listed, so its child renders only what changed | 225 s | |
| 521 |
| An ordinary commit is built from the paths it changes alone | 178 s | |
| 522 |
| The diff sets aside lines found on one side only, as Git's xdiff does | 91 s | |
| 523 |
| It diffs from the base's rendering rather than rendering again | 81 s | |
| 524 |
| Tree views updated in place; merges add the other side without text | 56 s | |
| 525 |
| File graphs keep their order as edits splice it | 38.8 s | |
| 526 |
|
| 527 |
Twenty text diffs took 92 of the 103 s the builder spent: files |
| 528 |
rewritten whole (line endings changed), on which Myers' algorithm is |
| 529 |
quadratic. Rendering a long-lived file walked every line it ever had; |
| 530 |
it now follows its live lines. Every step verified all 5 000 commits |
| 531 |
(13.4 million files) clean. |
| 532 |
|
| 533 |
|
| 534 |
|
| 535 |
| 5 000 commits | 312 s, 2.1 GB | 38.8 s, 1.5 GB | |
| 536 |
| 20 000 commits | 4 530 s, 6.7 GB | 925 s, 6.4 GB | |
| 537 |
|
| da652d63 Andy McCall ↶ |
538 |
### Store size (2026-09-30, backlog P1) |
| 539 |
|
| 540 |
The Linux tip's store was 448 MB, and 390 MB after `oxy gc`, against |
| 541 |
311 MB for Git after `git gc`. The file contents cut into the store's |
| 542 |
chunks compress, at zstd level 3, to 312 MB, level with Git; at level 9 |
| 543 |
to 284 MB (12.9 s on four cores), at level 19 to 270 MB (314 s). The |
| 544 |
rest was two things: |
| 545 |
- `oxy gc` left the first pack as written: packs merge only above 16. |
| 546 |
- A text blob holds its lines as a CBOR array, so every line has a |
| 547 |
length header: 62 MB more raw, 35 MB more compressed for the tree's |
| 548 |
46 308 files of 4 KiB or more. |
| 549 |
|
| 550 |
`oxy gc` now repacks everything into one pack at zstd level 9, compressed |
| 36695c5e Andy McCall ↶ |
551 |
on every core: 357 MB (349 MB pack) in 52 s. Change format 3 then stores |
| 552 |
a text blob as the lines' bytes: |
| 553 |
|
| 554 |
|
| 555 |
|
| 556 |
| Format 2, gc at level 3 | 390 MB | 41 s | |
| 557 |
| Format 2, gc repacks at level 9 | 357 MB | | |
| 558 |
| Format 3 (Earth at load average 11) | 324 MB (objects 306 MB) | 32.6 s, 4.4 GB | |
| 559 |
| Git | 311 MB (.git) | 33.7 to 37.9 s, 42 MB | |
| da652d63 Andy McCall ↶ |
560 |
|
| d77e1702 Andy McCall ↶ |
561 |
### fsck (2026-09-30) |
| 562 |
|
| 563 |
On the Linux tip in change format 3, `oxy fsck` took 24.7 s and 2.8 GB: |
| 564 |
its references pass read every change whole, the first one's 1.8 GB of |
| 565 |
text into memory, to check dependencies, authors and blobs. It now reads |
| 566 |
a change's skeleton and checks its text as the blobs it is stored in (the |
| 567 |
objects pass has checked every object already): 17.2 s and 984 MB, the |
| 568 |
references pass 1.7 s. Phases are timed (`OXY_TIMINGS`): objects 5.1 s, |
| 569 |
references 1.7 s, states and checkpoints 10.4 s. |
| 570 |
|
| 75b6c866 Andy McCall ↶ |
571 |
|
| 572 |
### The full run (2026-09-30 06:24, main-0ba9b2e4) |
| 573 |
|
| 574 |
`~/bench/linux-bench.sh` on Earth, at load average 3.8 to 4.5 (flow jobs |
| 575 |
were running). Times are wall clock; memory is peak resident size. |
| 576 |
|
| 577 |
|
| 578 |
|
| 579 |
| init and commit the tip | 38.50 s, 4.4 GB | 38.77 s, 42 MB | |
| 580 |
| status, clean (three runs) | 0.68 to 0.76 s, 123 MB | 0.35 to 0.76 s, 31 MB | |
| 581 |
| status, one file | 3.32 s, 641 MB | 0.59 s | |
| 582 |
| diff, one file | 2.93 s | 0.24 s | |
| 583 |
| commit, one file | 4.74 s, 752 MB | 0.45 s | |
| 584 |
| status, 1 000 files | 3.88 s, 811 MB | 0.41 s | |
| 585 |
| diff, 1 000 files | 2.83 s | 0.95 s | |
| 586 |
| commit, 1 000 files | 4.85 s, 922 MB | 0.57 s | |
| 587 |
| log -n 100 | 1.39 s, 291 MB | 0.01 s | |
| 588 |
| blame MAINTAINERS | 1.18 s, 335 MB | 0.09 s | |
| 589 |
| checkout the first state (run by hand) | 2.84 s | 0.42 s | |
| 590 |
| checkout back | 2.22 to 2.72 s, 491 MB | 0.47 s | |
| 591 |
| fsck | 22.10 s, 1.07 GB | 13.09 s | |
| 592 |
| gc | 39.66 s, 2.8 GB | 102.79 s | |
| 593 |
| store before gc | 414 MB | 648 MB | |
| 594 |
| store after gc | 324 MB | 295 MB | |
| 595 |
|
| 596 |
The script's own oxy checkout of the first state failed; its hash lacked |
| 597 |
the `#`, and the script is fixed. The time in the table is from running |
| 598 |
it by hand. |
| 599 |
|
| 600 |
Commit, one file: its graph keys now come from the state the commit |
| 601 |
extended rather than a reload of the new base (main-eff93613), which |
| 602 |
takes it to 3.05 to 3.27 s; the status after it takes 0.43 s. |
| 603 |
|
| 604 |
What is left for the everyday commands is mostly loading the base's |
| 605 |
history and its tree view (about 1 s of each) and rendering what |
| 606 |
changed. oxygen#41 proposes a base index that would make those reads |
| 607 |
proportional to what changed. |
| 60622d23 Andy McCall ↶ |
608 |
|
| 609 |
### A state index for the working copy's base (2026-09-30, oxygen#41) |
| 610 |
|
| 611 |
A snapshot replayed the base's history (0.70 s) and worked out its tree |
| 612 |
view (0.31 s) every time. Each workspace now keeps its base as a local |
| 613 |
state index: change metadata, the view, the introducers, every file's |
| 614 |
deferred history with its graph key, and the tree's maps, decoded only |
| 615 |
when a command needs them. The index is hashed, and a test build checks |
| 616 |
every read against a full load. The rest of a status that grew with |
| 617 |
the tree now follows the change: the manifest is parsed once and |
| 618 |
rewritten only when that helps; graph keys are looked up only for files |
| 619 |
the manifest knows; and the change is built from the changed paths. |
| 620 |
|
| 621 |
Linux tip, MAINTAINERS edited, oxy and Git alternating under the same |
| 622 |
load (Earth at load average 4.5, other jobs running): |
| 623 |
|
| 624 |
|
| 625 |
|
| 626 |
| status | 3.32 s, 641 MB | 1.26 to 1.39 s, 370 MB | 0.26 to 0.29 s | |
| 627 |
| diff | 2.93 s | 1.19 to 1.45 s | 0.16 to 0.25 s | |
| 628 |
| status, clean | 0.68 to 0.76 s | 0.41 to 0.43 s | 0.26 to 0.32 s | |
| 629 |
|
| e4b8bcf0 Andy McCall ↶ |
630 |
### Baseline for backlog 4 (2026-09-30 21:43, oxygen#41) |
| 631 |
|
| 632 |
`~/bench/linux-bench.sh`, now with three runs of each everyday command, |
| 633 |
oxy and Git alternating, and a local clone. Earth's load average was 7.2 |
| 634 |
at the start (no Flow jobs running), falling during the run; the first run |
| 635 |
of each set is the noisiest. |
| 636 |
|
| 637 |
|
| 638 |
|
| 639 |
| init and commit the tip | **33.3 s**, 3.1 GB | 38.2 s, 42 MB | |
| 640 |
| status, clean | 0.43 to 0.53 s, 75 MB | 0.34 to 0.46 s | |
| 641 |
| status, one file | 1.73 to 1.81 s (3.36 s first), 375 MB | 0.36 to 0.65 s | |
| 642 |
| diff, one file | 1.77 to 2.24 s | 0.24 to 0.29 s | |
| 643 |
| commit, one file | 3.10 s | 0.41 s | |
| 644 |
| status, 1 000 files | 1.62 s (4.21 s first), 573 MB | 0.28 to 0.38 s | |
| 645 |
| diff, 1 000 files | 1.54 to 2.40 s | 0.59 to 0.81 s | |
| 646 |
| commit, 1 000 files | 2.47 s | 0.53 s | |
| 647 |
| log -n 100 | 0.86 to 1.03 s | 0.00 s | |
| 648 |
| blame MAINTAINERS | 0.99 to 1.04 s | 0.07 s | |
| 649 |
| clone, local path | 56.3 s, 3.3 GB | 18.4 s | |
| 650 |
| checkout the first state, and back | 2.86 s, 3.00 s | 0.39 s, 0.50 s | |
| 651 |
| fsck | 24.4 s, 773 MB | 12.8 s | |
| 652 |
| gc | **48.1 s** | 100.5 s | |
| 653 |
| store before and after gc | 465 MB, 375 MB | 648 MB, 295 MB | |
| 654 |
|
| 655 |
oxy is now ahead of Git on the first commit (33 s against 38 s) and on gc |
| 656 |
(48 s against 100 s). The store after gc grew from 324 MB because it now |
| 657 |
includes the workspace's state index. |
| 658 |
|
| bd941440 Andy McCall ↶ |
659 |
|
| 660 |
### S1, first part: walk, manifest, lazy index, mimalloc (2026-09-30 22:39, oxygen#41) |
| 661 |
|
| 662 |
The same script after the first round of S1: a lister of our own in place |
| 663 |
of the ignore crate's walker, the manifest as a flat binary file with |
| 664 |
hashes as bytes, each file's history in the state index decoded only when |
| 665 |
used, maps built in path order, and mimalloc as the allocator. Earth's |
| 666 |
load average was 4 to 5 (no Flow jobs); runs alternate with Git. |
| 667 |
|
| 668 |
|
| 669 |
|
| 670 |
| init and commit the tip | **24.4 s**, 3.2 GB | 33.3 s, 3.1 GB | 36.2 s | |
| 671 |
| status, clean | **0.17 to 0.18 s**, 130 to 150 MB | 0.43 to 0.53 s | 0.22 to 0.23 s | |
| 672 |
| status, one file | 0.47 s (1.47 s first) | 1.73 to 1.81 s | 0.22 to 0.23 s | |
| 673 |
| diff, one file | 0.47 to 0.51 s | 1.77 to 2.24 s | 0.11 to 0.14 s | |
| 674 |
| commit, one file | 0.96 s | 3.10 s | 0.17 s | |
| 675 |
| status, 1 000 files | 0.65 s (1.99 s first) | 1.62 s | 0.23 to 0.24 s | |
| 676 |
| diff, 1 000 files | 0.65 s | 1.54 to 2.40 s | 0.46 to 0.48 s | |
| 677 |
| commit, 1 000 files | 1.20 s | 2.47 s | 0.50 s | |
| 678 |
| log -n 100 | 0.48 to 0.53 s | 0.86 to 1.03 s | 0.00 s | |
| 679 |
| blame MAINTAINERS | 0.59 s | 0.99 to 1.04 s | 0.07 s | |
| 680 |
| clone, local path | 36.7 s, 3.6 GB | 56.3 s | 18.0 s | |
| 681 |
| checkout the first state, and back | 1.48 s, 1.37 s | 2.86 s, 3.00 s | 0.35 s, 0.34 s | |
| 682 |
| fsck | 14.0 s, 1.0 GB | 24.4 s | 12.6 s | |
| 683 |
| gc | **29.9 s** | 48.1 s | 70.3 s | |
| 684 |
| store after gc | 369 MB | 375 MB | 295 MB | |
| 685 |
|
| 686 |
mimalloc alone took one-file status from 0.78 to 0.47 s and clean status |
| 687 |
from 0.26 to 0.18 s: status allocates hundreds of thousands of small |
| 688 |
values from every thread, and glibc's arena locks were the largest single |
| 689 |
cost. The first status after a commit (1.47 s) rebuilds the state index. |
| 690 |
|
| 691 |
Afterwards (22:55), log reads change metadata from the state index and a |
| 692 |
metadata cache in the store, and its signature marks no longer read each |
| 693 |
change whole: `log -n 100` takes 0.19 to 0.20 s, of which 9 ms is the log |
| 694 |
itself and the rest the check that the working copy is clean. |
| 695 |
|
| 696 |
Where one-file status goes now (0.47 s): listing the working copy about |
| 697 |
150 ms (the syscalls alone: `find` with a stat of each file takes 0.37 s |
| 698 |
on one core), loading the state index about 60 ms, reading the manifest |
| 699 |
about 40 ms, rendering and diffing the changed file about 100 ms. |
| e82591ec Andy McCall ↶ |
700 |
|
| 701 |
### S1 to S5, second round (2026-09-30 23:54, oxygen#41) |
| 702 |
|
| 703 |
After the second round: merge joins over whole-tree maps, a commit that |
| 704 |
works out keys only for what it changed and brings the state index up to |
| 705 |
date by replaying rather than rewriting it, the manifest checksummed and |
| 706 |
written without fsync, log and blame from the state index, checkout |
| 707 |
through the index with two indexes kept. Load average 4.2 at the start, |
| 708 |
no Flow jobs. |
| 709 |
|
| 710 |
|
| 711 |
|
| 712 |
| init and commit the tip | **21.9 s**, 3.2 GB | 24.4 s | 33.7 s | |
| 713 |
| status, clean | **0.17 to 0.19 s** | 0.17 to 0.18 s | 0.22 s | |
| 714 |
| status, one file | 0.40 to 0.41 s (1.10 s first) | 0.47 s | 0.22 to 0.24 s | |
| 715 |
| diff, one file | 0.39 to 0.48 s | 0.47 to 0.51 s | 0.10 to 0.11 s | |
| 716 |
| commit, one file | 0.67 s | 0.96 s | 0.21 s | |
| 717 |
| status, 1 000 files | 0.58 to 0.65 s | 0.65 s | 0.23 to 0.24 s | |
| 718 |
| diff, 1 000 files | 0.59 to 0.62 s | 0.65 s | 0.46 to 0.48 s | |
| 719 |
| commit, 1 000 files | 0.88 s | 1.20 s | 0.48 s | |
| 720 |
| log -n 100 | 0.17 to 0.19 s | 0.48 to 0.53 s | 0.00 s | |
| 721 |
| blame MAINTAINERS | 0.16 s | 0.59 s | 0.07 to 0.16 s | |
| 722 |
| clone, local path | 32.2 s, 3.6 GB | 36.7 s | 17.0 s | |
| 723 |
| checkout the first state, and back | 0.74 s, 0.74 s | 1.48 s, 1.37 s | 0.37 s, 0.33 s | |
| 724 |
| fsck | 13.6 s | 14.0 s | 12.6 s | |
| 725 |
| gc | **29.4 s** | 29.9 s | 70.2 s | |
| 726 |
| store after gc | 369 MB | 369 MB | 295 MB | |
| 727 |
|
| 728 |
The first one-file status after the first commit (1.10 s) builds the |
| 729 |
state index for the first time. Most of what remains in status is listing |
| 730 |
the tree (about 135 ms), loading the index (about 60 ms) and starting up; |
| 731 |
commit adds storing and recording with fsync (about 100 ms). |
| cd196037 Andy McCall ↶ |
732 |
|
| 733 |
### P5a: flow-casfs, a read-only FUSE mount of a state (2026-10-01, oxygenflow#21) |
| 734 |
|
| 735 |
The first flow-casfs (single-threaded, fuser 0.16, no read-ahead) on the |
| 736 |
Linux repository on Earth, with the H4 import and Flow jobs running (load |
| 737 |
average 10 to 11), so the times are high: |
| 738 |
|
| 739 |
|
| 740 |
|
| 741 |
| mount (state from the index 284 ms, tree of 102 327 entries 40 ms) | 0.52 s | |
| 742 |
| first read of MAINTAINERS (rendered) | 0.10 s | |
| 743 |
| `ls -lR`, rendering every file one at a time | 201 s | |
| 744 |
| full read afterwards (bytes in memory) | 0.77 s | |
| 745 |
| full read, new mount, renderings cached on disk | 20.8 s | |
| 746 |
| render cache on disk | 1.8 GB | |
| 747 |
|
| 748 |
`diff -r` between the mount and the working copy finds no difference. To |
| 749 |
reach the spec's targets: render in parallel and ahead of reads, take a |
| 750 |
file added once and never edited straight from its text blob, keep sizes |
| 751 |
in the render cache so a stat does not render, serve FUSE on several |
| 752 |
threads, and read the state index lazily for a mount under 100 ms. |
| 36041796 Andy McCall ↶ |
753 |
|
| 754 |
### Status with work in progress (2026-10-01, oxygen#41) |
| 755 |
|
| 756 |
`MAINTAINERS` edited on the Linux tree on Earth (load average 2.2, one |
| 757 |
Flow job running), oxy and Git alternating, three runs each: |
| 758 |
|
| 759 |
|
| 760 |
|
| 761 |
| status, one file edited (after a first status) | 0.42 to 0.46 s, 330 MB | **0.16 to 0.17 s**, 120 to 155 MB | 0.20 to 0.21 s | |
| 762 |
| status, clean | 0.18 to 0.20 s | **0.15 to 0.16 s** | 0.20 to 0.24 s | |
| 763 |
| diff, one file | 0.43 s | 0.40 to 0.41 s | 0.08 to 0.09 s | |
| 764 |
|
| 765 |
Before, the manifest only proved a clean working copy. With uncommitted |
| 766 |
work every status listed and snapshotted the tree again, loaded the state |
| 767 |
index and rendered the base (listing 164 ms, index 60 ms, base 33 ms). Now |
| 768 |
a snapshot leaves a status cache keyed by the working-copy change and the |
| 769 |
manifest's checksum, and keeps the manifest current for the edited files. |
| 770 |
A later status that finds every file as the manifest says answers from |
| 771 |
the cache. Test builds check that answer against a snapshot. Diff still |
| 772 |
renders the base; it is next. |
| 77445673 Andy McCall ↶ |
773 |
|
| 774 |
Then diff (same day, load average 4.2, Git 0.10 s throughout): while the |
| 775 |
status cache holds, `oxy diff` renders the stored working-copy change |
| 776 |
against its base loaded from the state index, rather than snapshotting. |
| 777 |
0.34 to 0.35 s and 250 MB (from 0.41 s and 360 MB). Rendering it through |
| 778 |
`change_base`, which reads the base's changes rather than the index, was |
| 779 |
slower than a snapshot (0.9 s) and was not kept. What remains is the |
| 780 |
status check's listing (about 130 ms), the index (60 ms) and rendering. |
| 781 |
|
| 1ec3f008 Andy McCall ↶ |
782 |
Then directory times (same evening, load average 3.1): the manifest |
| 783 |
(format 3) keeps every directory the walk listed with its time, read |
| 784 |
before the directory was. While no directory's time has moved, the check |
| 785 |
stats the tracked files and does not list the tree; a directory whose |
| 786 |
entries changed sends it back to a listing. |
| 787 |
|
| 788 |
|
| 789 |
|
| 790 |
| status, one file edited | 0.16 to 0.20 s, 120 to 155 MB | **0.10 to 0.12 s, 80 MB** | 0.22 s | |
| 791 |
| status, clean | 0.15 to 0.21 s | **0.09 to 0.12 s, 80 MB** | 0.20 to 0.26 s | |
| 792 |
| diff, one file | 0.34 s | 0.24 to 0.25 s | 0.09 to 0.13 s | |
| 793 |
|
| 10b4fd43 Andy McCall ↶ |
794 |
Diff, last round (load average 2.7): a change's base renders only its |
| 795 |
edited files without first building a set of every other file to leave |
| 796 |
out, and `oxy diff` no longer frees its structures just before the |
| 797 |
process ends. 0.19 to 0.21 s (from 0.25 s; Git 0.08 s). What remains: |
| 798 |
the stat check 78 ms (Git stats the same files), the state index 58 ms |
| 799 |
(39 of them building the tree view), the base's full file map 21 ms. |
| 800 |
|
| e226a6cc Andy McCall ↶ |
801 |
### Commit, one file (2026-10-01, oxygen#41) |
| 802 |
|
| 803 |
`~/bench/commit-vs-git.sh`: commits go forward and both repositories are |
| 804 |
put back at the end (an `oxy undo` between runs dropped the manifest, and |
| 805 |
its rehash of the tree evicted the page cache Git's runs relied on, which |
| 806 |
made both sides' numbers meaningless). Load average 3 to 4. |
| 807 |
|
| 808 |
|
| 809 |
|
| 810 |
| commit after a status | 0.68 s | **0.36 to 0.44 s** | 0.14 to 0.17 s | |
| 811 |
| commit straight after the edit | 0.68 s | 0.62 to 0.69 s | 0.14 to 0.17 s | |
| 812 |
|
| 813 |
After a status, the commit takes the status's snapshot from the cache. |
| 814 |
Straight after an edit, the stat check now hands its stats to the |
| 815 |
snapshot as its listing (8 ms instead of 122 ms), but checks every file |
| 816 |
rather than stopping at the first difference, so the gain is small. What |
| 817 |
remains is mostly durability: 18 fsyncs take 210 ms of the commit (one |
| 818 |
alone 120 ms). Git does not sync loose objects by default. |
| 819 |
|
| b30a880f Andy McCall ↶ |
820 |
Fewer waits on the disk (same evening): a batch's loose objects are |
| 821 |
written together, synced in parallel, renamed and their directories |
| 822 |
synced once each; a commit runs in one batch; packing keeps the emptied |
| 823 |
fan-out directories, and a missing one has all 256 created with one sync |
| 824 |
of `loose` (that sync alone took 130 ms on a busy disk). Serial syncs per |
| 825 |
commit fall from 18 to about 7; each still waits on the same ext4 journal |
| 826 |
commit, so the gain is modest. Old and new builds alternating, five |
| 827 |
commits after a status each: median 0.39 s before, 0.34 s after (Git |
| 828 |
0.14 to 0.16 s). |
| 829 |
|
| cd4eb55e Andy McCall ↶ |
830 |
A commit's own snapshot (straight after an edit) no longer stores its |
| 831 |
working-copy change or records an operation for it: the commit's |
| 832 |
operation, recorded straight after, replaces the working copy. Old and |
| 833 |
new builds alternating, six direct commits each (load average 4.4): median |
| 834 |
0.61 s before, 0.58 s after (Git 0.16 to 0.27 s at that load). |
| 835 |
|
| 33472ccc Andy McCall ↶ |
836 |
### log -n 100 (2026-10-02, oxygen#41) |
| 837 |
|
| 838 |
`oxy log` no longer checks the files on disk: its working-copy row |
| 839 |
describes the working copy as last recorded (with edits, or not; the |
| 840 |
manifest's conflict flag), as Git's log never looks at the working tree. |
| 841 |
The entries come from the state index's change metadata. |
| 842 |
|
| 843 |
|
| 844 |
|
| 845 |
| Linux tree (Earth) | 0.17 s | **0.03 s** | 0.00 s | |
| 846 |
| oxygen, 388 changes (workstation) | 0.06 s, 79 MB | **0.03 s, 22 MB** | | |
| 847 |
| vscode slice, 10 000 commits (Earth, warm) | | **0.02 s** | | |
| 848 |
|
| 849 |
What remains on oxygen is checking each row's signature (30 ms for 100). |
| 850 |
|
| 5ae7533f Andy McCall ↶ |
851 |
### Against Git tuned for large repositories (2026-10-02) |
| 852 |
|
| 853 |
Git as it comes compares an untuned Git with an oxy that uses every |
| 854 |
cache it has. `~/bench/vs-git-tuned.sh` adds a copy of the Git repository |
| 855 |
tuned as Git recommends for large ones: `feature.manyFiles` (index v4 and |
| 856 |
more), `core.untrackedCache`, and a commit-graph. Not `core.fsmonitor`: |
| 857 |
Git 2.53 has no file-watcher daemon on Linux, and Watchman is not |
| 858 |
installed. Earth, load average 1.6, three runs each, alternating. |
| 859 |
|
| 860 |
|
| 861 |
|
| 862 |
| status, clean | 0.16 s | 0.20 to 0.21 s | **0.10 s** | |
| 863 |
| status, one file edited | 0.12 to 0.13 s | 0.21 to 0.22 s | **0.10 to 0.11 s** | |
| 864 |
| diff, one file | 0.22 to 0.23 s | 0.09 s | **0.08 to 0.09 s** | |
| 865 |
| log -n 100 | 0.03 s | **0.00 s** | **0.00 s** | |
| 866 |
| blame MAINTAINERS | 0.16 s | **0.06 s** | **0.06 s** | |
| 867 |
|
| 868 |
Tuned Git is ahead of oxy on all five. Its untracked cache is what oxy's |
| 869 |
directory times do, and with it Git's status is a little faster than |
| 870 |
oxy's. oxy beats Git as it comes on status only. Claims of speed against |
| 871 |
Git should say which Git. |
| 872 |
|
| e96a430c Andy McCall ↶ |
873 |
Then the stat check by directory (same night): tracked files are grouped |
| 874 |
by directory, each directory opened once, and each file statted relative |
| 875 |
to it (`fstatat`), so a stat resolves one name rather than every component |
| 876 |
from the root (the kernel's path lookup was most of a clean status). The |
| 877 |
stat check went from 84 ms to 73 ms; a clean status is 0.10 to 0.12 s, |
| 878 |
level with tuned Git (0.10 to 0.11 s). What is left is the 96 000 stat |
| 879 |
calls themselves, the same work Git does; doing less needs a file watcher |
| 880 |
(Git's fsmonitor), which oxy does not have. |
| 881 |
|
| fddf64ba Andy McCall ↶ |
882 |
### Blame, and the tree view (2026-10-02, oxygen#41) |
| 883 |
|
| 884 |
`oxy blame` writes through one buffered lock on stdout and formats each |
| 885 |
change's id, author and age once (30 219 lines of MAINTAINERS share a few |
| 886 |
changes); printing went to 4 ms. The state index's tree view is built in |
| 887 |
one step from its sorted paths rather than by an insert per path: 37 ms |
| 888 |
to 21 ms, for every command that loads the index. Quiet Earth (load |
| 889 |
average 0.4), three runs each, alternating: |
| 890 |
|
| 891 |
|
| 892 |
|
| 893 |
| status, clean | 0.10 s | 0.19 to 0.23 s | 0.09 to 0.10 s | |
| 894 |
| status, one file edited | 0.10 s | 0.19 s | 0.09 s | |
| 895 |
| diff, one file | 0.17 to 0.19 s | 0.08 s | 0.08 s | |
| 896 |
| log -n 100 | 0.03 s | 0.00 s | 0.00 s | |
| 897 |
| blame MAINTAINERS | **0.08 s** (from 0.16) | 0.06 s | 0.06 s | |
| 898 |
|
| 0389c3e8 Andy McCall ↶ |
899 |
### Checkout, first measurement against Git (2026-10-02, oxygen#41) |
| 900 |
|
| 901 |
`~/bench/checkout-vs-git.sh`: to the first state (1 001 files differ) and |
| 902 |
back, oxy, Git and tuned Git alternating, three each (load average 1.6). |
| 903 |
|
| 904 |
|
| 905 |
|
| 906 |
| to the first state | 0.65 to 0.71 s | 0.26 to 0.27 s | 0.24 to 0.26 s | |
| 907 |
| and back | 0.71 to 0.73 s | 0.23 to 0.26 s | 0.23 to 0.24 s | |
| 908 |
|
| 909 |
Where a checkout's time goes: the manifest check 134 ms (listing: see |
| 910 |
below), planning 67 ms, the 1 001 texts read from the store (134 ms) or |
| 911 |
their graphs built (141 ms) going back, writing 65 to 82 ms, recording |
| 912 |
115 to 143 ms, the manifest 45 ms. |
| 913 |
|
| 914 |
Found on the way, now fixed: a manifest written by a checkout kept no |
| 915 |
directory times, and directories a checkout had just written stayed |
| 916 |
within the racy window of the manifest's time for good, so every status |
| 917 |
after a checkout listed the tree. A clean check that verified the tree |
| 918 |
now restamps the manifest with the time it began, keeping the |
| 919 |
directories it listed (Git restamps its index the same way): the first |
| 920 |
status after a checkout takes 0.15 s, the ones after it 0.10 s again. |
| 921 |
|