OXYGENHUB
# 72215f29df4c oxygen / docs / benchmarks.md
31.8 KiB · Markdown · last changed Benchmarks: the second round on the Linux tree (refs #41) 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.

2026-09-28: O10g, first part: status snapshots skip unchanged files

The manifest now records, per text file equal to the base, the key of the base graph it matched: the file's node, its checkpoint graph (blob, inline value or none) and the set of later changes that edited it (spec §12.4). When a file's stat and key both still match, the snapshot neither reads nor renders it.

status, log, show, tag and bookmark use a partial snapshot. It loads the base for its history and builds only the graphs of files that are not known unchanged. commit, diff and the rest still build the whole base.

10 000 files of 40 lines, 22 changes, a version 2 checkpoint after 21 of them; OXY_TIMINGS=1, clean working copy:

OperationBeforeAfter
status0.55 s0.22 s
status peak memory259 MB51 MB
log0.7–0.8 s0.38 s
diff (full snapshot)0.56 s0.57 s
Phase of a clean status snapshotBeforeAfter
load base (whole / history only)317 ms31 ms
render base91 ms25 ms
read working copy68 ms50 ms
diff against base41 ms28 ms
write manifest33 ms35 ms

The same for commit and diff comes next (O10h). There, only the graphs the change touches are needed, and materialized states can be cached.

2026-09-29: O10h, commit, diff and show build only what they touch

Commit, working-copy diff, hunks and discard use the partial snapshot of O10g. Files known unchanged are neither read nor rendered, and only the graphs of the files the working copy changes are built. Showing a committed change (oxy show, oxy diff CHANGE) builds the graphs of the files that change edits and leaves the rest unrendered.

Same repository as O10g (10 000 files of 40 lines):

OperationAfter O10gAfter O10h
diff (working copy)0.57 s, 244 MB0.22 s, 52 MB
commit (one file)0.58 s, 245 MB0.23 s, 52 MB
show (a one-file change)not measured0.53 s, 145 MB

Caching materialized states, planned alongside, is not needed now that these commands render almost nothing.

2026-09-29: P5c, importing microsoft/vscode, first part

oxy git import on slices of microsoft/vscode's main (166 412 commits in total), taken in the importer's own order (git log --reverse --topo-order). The first 5 000 commits reach May 2016 with about 4 700 files.

The importer did work proportional to the whole tree for every commit:

  • It cloned the previous state.
  • It rebuilt a merge's first parent from history.
  • It rendered, read and compared every file.
  • It scanned every entry, with an allocation each, to decide whether each directory was still needed.

Now:

  • Files with the same Git blob and mode as in the first parent's tree are marked unchanged and skipped, as a clean working copy's are. Paths that a merge's other parents touched are excluded.
  • A commit's state is kept while first-parent children remain, up to 8, and the last child takes it without a clone.
  • Needed directories come from one set of ancestors. That code is shared by snapshot and commit.
SliceBeforeAfter
300 commits (35 merges)65.1 s13.6 s
1 000 commits (95 merges)271 s120 s, 3.3 GB
5 000 commits (360 merges)not finished after 80 min412 s, 6.3 GB, 17 MB store

Where the 5 000-commit import spends its time (summed per phase): render base 172 s, build change 99 s, bases 65 s, git ls-tree 56 s, read blobs 10 s, store 5 s.

Correctness: oxy git verify reproduces every commit of the 300 and 1 000-commit imports (1.9 million file comparisons for 1 000). A test covers the case that must not be skipped: a -s ours merge whose other side edited a file.

Next: rendering the base is still O(files) per commit, and memory grows with the kept states. The full import follows.

Linux, stage 1: the tip as one commit (2026-09-29)

The Linux tree at its tip: 95 943 files, 1.8 GB, on Earth (4 cores, 31 GiB), each run capped at 14 GB.

Gitoxy beforeoxy, change format 2
First commit33.7 s, 42 MBfailed, 10.9 GB55.3 s, 9.9 GB
Storeunreadable442 MB
Clean status0.26 to 0.31 skilled at 14 GB

Before format 2 the commit made one change of 1.8 GB. That was over the 1 GiB an object may be, and the repository would not open afterwards (oxygen#33, oxygen#34). Format 2 stores each insert of 4 KiB or more as a text blob, so the change stays small.

Memory is the next problem. Every line is kept as its own allocation, once in the change and again in the file graph, and status builds every graph (L1b).

Status after the commit (2026-09-29, oxygen#35)

A profile with the counting allocator (--features alloc-stats, OXY_TIMINGS=1) of the first clean status, before it was killed:

PhaseTimeLive afterAllocated
Load base history17.5 s3.2 GB16 GB in 96 M allocations
Read working copy79.5 s4.9 GB1.8 GB
Load and render basekilled at 14 GB

It had two causes, and both are fixed:

  • History held every line. A history-only state now reads a stored insert's text blob only when it builds that file's graph, and a load no longer hashes each change again, which for format 2 means chunking all its text.
  • The manifest had no graph keys. They are now recorded at commit, so the next status neither reads nor renders an unchanged file.
Clean status on the Linux tipTimePeak RSS
Beforekilled at 14 GB
Lazy text and keys at commit3.7 s768 MB
And answered from the manifest0.72 s120 MB
Git0.22 s31 MB

A clean status is now answered from the manifest alone, loading no history. The manifest also records the executable bit, since a mode change alters neither size nor time. Any change, a manifest older than the racy window, or subrepos send it back to a full snapshot.

Still to do: status to the 0.4 s target (the walk and reading the manifest); a status without a usable manifest still builds every graph and is killed at 14 GB; fsck is killed at 14 GB; the first commit takes 57 s and 10.2 GB.

Checkout and clone (2026-09-29, oxygen#35)

A checkout built and rendered every file of the target, read the whole working copy, and after writing walked and hashed it all again for the manifest. Switching bookmarks or cloning the Linux tree was killed at 14 GB. Now:

  • a file is skipped when the manifest shows it unchanged and its graph is the same in both states;
  • the rest are rendered 2 048 at a time, and each batch's graphs are dropped once written;
  • the manifest is built from what the checkout already knows.
Linux tipoxy beforeoxy nowGit
Switch bookmark (one file differs)killed at 14 GB7.1 to 7.4 s, 1.3 GB0.18 to 0.22 s, 39 MB
Local clonekilled at 14 GB98 s, 7.9 GB9.8 s, 469 MB

A history-only load now reads a change's skeleton: the inline lines of its inserts are stepped over, not decoded, and are read from the change only for a graph that is built. A one-file switch then takes 5.4 to 5.6 s and 0.8 GB, and the status after it 3.3 s and 600 MB (from 3.8 s).

Before that, most of a switch was loading history: the base's and the target's history each decode the one huge first change (1.2 s and 364 MB each). A clone holds the whole bundle in memory.

log, blame and fsck (2026-09-30, oxygen#35)

The first full run of ~/bench/linux-bench.sh against this work found log -n 100 taking 28 s and 4.7 GB, and blame killed at 14 GB:

  • log. Checking each change's signature read the whole change, and for the first change that meant all 1.8 GB of its text. Signature checks, the dependency walks of fetch and push, and the prompt now read a change's metadata from its skeleton, and log answers the working copy's state from the manifest when it proves it clean.
  • blame. It took a full snapshot, building every graph, to blame one file; it now loads the base's history and builds that file's graph.
  • fsck. A bookmark's state was checked by loading it whole. It is now checked a batch of graphs at a time, deleted files' graphs included.
Linux tipBeforeNowGit
log -n 10028 s, 4.7 GB2.0 s, 435 MB0.16 s
blame MAINTAINERSkilled at 14 GB1.1 s, 315 MB0.31 s
fsckkilled at 14 GB123 s, 4.5 GBto measure again

One file changed (2026-09-30, oxygen#35)

A status with one file changed (MAINTAINERS) took 4.1 s: the walk ran on one thread (0.83 s), and the manifest was written by statting all 96 000 files again (0.57 s). The walk now runs in parallel and each file's stat is carried from the walk into the manifest. It now takes 2.5 s: the base's history 0.5 s, its tree view and graph keys 0.38 s, the walk 0.35 s, rendering the changed file 0.47 s, the diff 0.18 s, and the manifest 0.14 s. (Git: 0.23 s.)

The first commit (2026-09-30, oxygen#35)

A profile of the first commit of the Linux tip (61 s, 10 GB RSS): read the working copy 2.3 s (1.6 GB of content), diff against the empty base 6.2 s (4.2 GB live, a Vec per line), hash and record 18.8 s, then the commit itself about 33 s. The text of the one huge change was chunked and hashed three or four times on one thread (hashing the working-copy change, storing it, storing the commit, adding it to the state), the commit copied every line of the working copy, and the chunks were compressed on one thread.

Now:

  • a snapshot encodes the working copy once, and hashes and stores that one encoding;
  • a whole commit moves the working copy's lines rather than copying them, and joins the state under the hash it was stored by;
  • text blobs are made on every core, 256 inserts at a time;
  • the chunks of a large change are compressed on every core (Store::put_many).
First commit, Linux tipTimePeak (counted)RSS
Before61 s7.1 GB10.1 GB
After40.7 s7.4 GB8.7 GB
Git33.7 to 37.9 s42 MB

The peak is now in storing the change, which holds every text's content at once; the lines themselves (a Vec each) are the rest.

Clone (2026-09-30, oxygen#35)

A local clone of the Linux tip took 96 s and 5.2 GB RSS. Its checkout spent most of its time rendering: working out each file's line order allocated three sets per line (the contraction of the graph), 93 GB in all, although every file of a fresh tree is one chain of lines. And the import hashed each change three times, for the huge first change chunking its 1.8 GB of text each time.

  • A graph that is one chain from start to end with nothing deleted (an edge to End besides the next line orders nothing) is read off in order; any other graph takes the general algorithm, which also skips its contraction where a line's successors are all kept. A test checks the two agree.
  • A bundle carries each change's hash when its maker knows it (the store it came from, or the received bytes); never from the wire. An import hashes each change once, by storing it, and checks it is the named one.
Local clone, Linux tipTimeRSS
Before96 s (81.6 s in a quieter run)5.2 to 5.4 GB
After61.0 s4.1 GB
The change read once for all batches56.8 s4.7 GB
Changes moved as stored (load average 5.2)48.2 s3.9 GB
Git9.6 s469 MB

A fetch between repositories on one machine now moves changes as they are stored, their text blobs and chunks among the objects, instead of decoding every change's text on one side and chunking, hashing and compressing it again on the other; the import stores all the objects at once, compressed on every core, and refuses a change whose text blob did not come.

Git import: the vscode slice (2026-09-30, oxygen#30)

The 1 747-commit vscode slice (to merge 0babb4299131) had one file as a name conflict at every commit from that merge on: 51 verify differences. There were three causes, all from Git having no identity for a directory:

  • a directory deleted on one line and made again got a second node for the same path;
  • a placement left under a directory another line removed was out of sight, so no merge could retire it;
  • a file whose only placements were under removed directories (an orphan) could not be seen either.

Now an import brings a directory back rather than making a second node, and a merge retires a kept node's other placements and every orphan.

oxy git verify built and rendered every file of every commit and read every Git blob; it now loads each commit's history and builds only the files whose graph (by key) or blob it has not already found equal to that Git blob.

vscode slice, 1 747 commitsBeforeAfter
Import128.6 s132.1 s
Differences51none: 3 327 291 files compared
Verify1 369 to 1 444 s, 832 MB114.6 s, 441 MB

Git import memory (2026-09-30, backlog H2)

An import kept up to eight full states (every file's graph) for commits whose children were still to come. It now keeps history-only states and builds the graphs of only the files a commit may change; unchanged ones were already neither read nor rendered. An imported change joins its base under the hash it was stored by, instead of being encoded again.

vscode, first 5 000 commitsImportPeak RSSVerify
Before (2026-09-29)412 s6.3 GB
After309 s2.3 GBevery commit (13.4 million files) in 978 s

Verify loaded each commit's history from scratch. It now keeps the state of a commit whose children are still to come, as the import does, and a child adds only the changes its parent lacks. Those states stay history only: graphs are built beside them (State::build_graphs_detached) and dropped after rendering, so changes are added without their text and every file keeps its graph key, which the cache of verified files needs. The same 5 000 commits verify in 166.5 s (from 978 s), 1.3 GB.

Git import at scale (2026-09-30, oxygen#25)

An imported commit cost what the whole tree is: git ls-tree of every path, a tree view worked out again, every path compared, and every changed file rendered from its whole history. Phases timed over the first 5 000 vscode commits (OXY_TIMINGS, repeated phases summed) led the work, one cost at a time:

Change5 000 commits
Before312 s
Each commit's tree is its parent's plus the diff, from one git diff-tree --stdin245 s
A parent not kept has its tree listed, so its child renders only what changed225 s
An ordinary commit is built from the paths it changes alone178 s
The diff sets aside lines found on one side only, as Git's xdiff does91 s
It diffs from the base's rendering rather than rendering again81 s
Tree views updated in place; merges add the other side without text56 s
File graphs keep their order as edits splice it38.8 s

Twenty text diffs took 92 of the 103 s the builder spent: files rewritten whole (line endings changed), on which Myers' algorithm is quadratic. Rendering a long-lived file walked every line it ever had; it now follows its live lines. Every step verified all 5 000 commits (13.4 million files) clean.

vscode sliceBeforeAfter
5 000 commits312 s, 2.1 GB38.8 s, 1.5 GB
20 000 commits4 530 s, 6.7 GB925 s, 6.4 GB

Store size (2026-09-30, backlog P1)

The Linux tip's store was 448 MB, and 390 MB after oxy gc, against 311 MB for Git after git gc. The file contents cut into the store's chunks compress, at zstd level 3, to 312 MB, level with Git; at level 9 to 284 MB (12.9 s on four cores), at level 19 to 270 MB (314 s). The rest was two things:

  • oxy gc left the first pack as written: packs merge only above 16.
  • A text blob holds its lines as a CBOR array, so every line has a length header: 62 MB more raw, 35 MB more compressed for the tree's 46 308 files of 4 KiB or more.

oxy gc now repacks everything into one pack at zstd level 9, compressed on every core: 357 MB (349 MB pack) in 52 s. Change format 3 then stores a text blob as the lines' bytes:

Linux tipStore after gcFirst commit
Format 2, gc at level 3390 MB41 s
Format 2, gc repacks at level 9357 MB
Format 3 (Earth at load average 11)324 MB (objects 306 MB)32.6 s, 4.4 GB
Git311 MB (.git)33.7 to 37.9 s, 42 MB

fsck (2026-09-30)

On the Linux tip in change format 3, oxy fsck took 24.7 s and 2.8 GB: its references pass read every change whole, the first one's 1.8 GB of text into memory, to check dependencies, authors and blobs. It now reads a change's skeleton and checks its text as the blobs it is stored in (the objects pass has checked every object already): 17.2 s and 984 MB, the references pass 1.7 s. Phases are timed (OXY_TIMINGS): objects 5.1 s, references 1.7 s, states and checkpoints 10.4 s.

The full run (2026-09-30 06:24, main-0ba9b2e4)

~/bench/linux-bench.sh on Earth, at load average 3.8 to 4.5 (flow jobs were running). Times are wall clock; memory is peak resident size.

OperationoxyGit
init and commit the tip38.50 s, 4.4 GB38.77 s, 42 MB
status, clean (three runs)0.68 to 0.76 s, 123 MB0.35 to 0.76 s, 31 MB
status, one file3.32 s, 641 MB0.59 s
diff, one file2.93 s0.24 s
commit, one file4.74 s, 752 MB0.45 s
status, 1 000 files3.88 s, 811 MB0.41 s
diff, 1 000 files2.83 s0.95 s
commit, 1 000 files4.85 s, 922 MB0.57 s
log -n 1001.39 s, 291 MB0.01 s
blame MAINTAINERS1.18 s, 335 MB0.09 s
checkout the first state (run by hand)2.84 s0.42 s
checkout back2.22 to 2.72 s, 491 MB0.47 s
fsck22.10 s, 1.07 GB13.09 s
gc39.66 s, 2.8 GB102.79 s
store before gc414 MB648 MB
store after gc324 MB295 MB

The script's own oxy checkout of the first state failed; its hash lacked the #, and the script is fixed. The time in the table is from running it by hand.

Commit, one file: its graph keys now come from the state the commit extended rather than a reload of the new base (main-eff93613), which takes it to 3.05 to 3.27 s; the status after it takes 0.43 s.

What is left for the everyday commands is mostly loading the base's history and its tree view (about 1 s of each) and rendering what changed. oxygen#41 proposes a base index that would make those reads proportional to what changed.

A state index for the working copy's base (2026-09-30, oxygen#41)

A snapshot replayed the base's history (0.70 s) and worked out its tree view (0.31 s) every time. Each workspace now keeps its base as a local state index: change metadata, the view, the introducers, every file's deferred history with its graph key, and the tree's maps, decoded only when a command needs them. The index is hashed, and a test build checks every read against a full load. The rest of a status that grew with the tree now follows the change: the manifest is parsed once and rewritten only when that helps; graph keys are looked up only for files the manifest knows; and the change is built from the changed paths.

Linux tip, MAINTAINERS edited, oxy and Git alternating under the same load (Earth at load average 4.5, other jobs running):

oxy (06:24)oxy nowGit
status3.32 s, 641 MB1.26 to 1.39 s, 370 MB0.26 to 0.29 s
diff2.93 s1.19 to 1.45 s0.16 to 0.25 s
status, clean0.68 to 0.76 s0.41 to 0.43 s0.26 to 0.32 s

Baseline for backlog 4 (2026-09-30 21:43, oxygen#41)

~/bench/linux-bench.sh, now with three runs of each everyday command, oxy and Git alternating, and a local clone. Earth's load average was 7.2 at the start (no Flow jobs running), falling during the run; the first run of each set is the noisiest.

OperationoxyGit
init and commit the tip33.3 s, 3.1 GB38.2 s, 42 MB
status, clean0.43 to 0.53 s, 75 MB0.34 to 0.46 s
status, one file1.73 to 1.81 s (3.36 s first), 375 MB0.36 to 0.65 s
diff, one file1.77 to 2.24 s0.24 to 0.29 s
commit, one file3.10 s0.41 s
status, 1 000 files1.62 s (4.21 s first), 573 MB0.28 to 0.38 s
diff, 1 000 files1.54 to 2.40 s0.59 to 0.81 s
commit, 1 000 files2.47 s0.53 s
log -n 1000.86 to 1.03 s0.00 s
blame MAINTAINERS0.99 to 1.04 s0.07 s
clone, local path56.3 s, 3.3 GB18.4 s
checkout the first state, and back2.86 s, 3.00 s0.39 s, 0.50 s
fsck24.4 s, 773 MB12.8 s
gc48.1 s100.5 s
store before and after gc465 MB, 375 MB648 MB, 295 MB

oxy is now ahead of Git on the first commit (33 s against 38 s) and on gc (48 s against 100 s). The store after gc grew from 324 MB because it now includes the workspace's state index.

S1, first part: walk, manifest, lazy index, mimalloc (2026-09-30 22:39, oxygen#41)

The same script after the first round of S1: a lister of our own in place of the ignore crate's walker, the manifest as a flat binary file with hashes as bytes, each file's history in the state index decoded only when used, maps built in path order, and mimalloc as the allocator. Earth's load average was 4 to 5 (no Flow jobs); runs alternate with Git.

Operationoxybefore (21:43)Git
init and commit the tip24.4 s, 3.2 GB33.3 s, 3.1 GB36.2 s
status, clean0.17 to 0.18 s, 130 to 150 MB0.43 to 0.53 s0.22 to 0.23 s
status, one file0.47 s (1.47 s first)1.73 to 1.81 s0.22 to 0.23 s
diff, one file0.47 to 0.51 s1.77 to 2.24 s0.11 to 0.14 s
commit, one file0.96 s3.10 s0.17 s
status, 1 000 files0.65 s (1.99 s first)1.62 s0.23 to 0.24 s
diff, 1 000 files0.65 s1.54 to 2.40 s0.46 to 0.48 s
commit, 1 000 files1.20 s2.47 s0.50 s
log -n 1000.48 to 0.53 s0.86 to 1.03 s0.00 s
blame MAINTAINERS0.59 s0.99 to 1.04 s0.07 s
clone, local path36.7 s, 3.6 GB56.3 s18.0 s
checkout the first state, and back1.48 s, 1.37 s2.86 s, 3.00 s0.35 s, 0.34 s
fsck14.0 s, 1.0 GB24.4 s12.6 s
gc29.9 s48.1 s70.3 s
store after gc369 MB375 MB295 MB

mimalloc alone took one-file status from 0.78 to 0.47 s and clean status from 0.26 to 0.18 s: status allocates hundreds of thousands of small values from every thread, and glibc's arena locks were the largest single cost. The first status after a commit (1.47 s) rebuilds the state index.

Afterwards (22:55), log reads change metadata from the state index and a metadata cache in the store, and its signature marks no longer read each change whole: log -n 100 takes 0.19 to 0.20 s, of which 9 ms is the log itself and the rest the check that the working copy is clean.

Where one-file status goes now (0.47 s): listing the working copy about 150 ms (the syscalls alone: find with a stat of each file takes 0.37 s on one core), loading the state index about 60 ms, reading the manifest about 40 ms, rendering and diffing the changed file about 100 ms.

S1 to S5, second round (2026-09-30 23:54, oxygen#41)

After the second round: merge joins over whole-tree maps, a commit that works out keys only for what it changed and brings the state index up to date by replaying rather than rewriting it, the manifest checksummed and written without fsync, log and blame from the state index, checkout through the index with two indexes kept. Load average 4.2 at the start, no Flow jobs.

Operationoxy22:39Git
init and commit the tip21.9 s, 3.2 GB24.4 s33.7 s
status, clean0.17 to 0.19 s0.17 to 0.18 s0.22 s
status, one file0.40 to 0.41 s (1.10 s first)0.47 s0.22 to 0.24 s
diff, one file0.39 to 0.48 s0.47 to 0.51 s0.10 to 0.11 s
commit, one file0.67 s0.96 s0.21 s
status, 1 000 files0.58 to 0.65 s0.65 s0.23 to 0.24 s
diff, 1 000 files0.59 to 0.62 s0.65 s0.46 to 0.48 s
commit, 1 000 files0.88 s1.20 s0.48 s
log -n 1000.17 to 0.19 s0.48 to 0.53 s0.00 s
blame MAINTAINERS0.16 s0.59 s0.07 to 0.16 s
clone, local path32.2 s, 3.6 GB36.7 s17.0 s
checkout the first state, and back0.74 s, 0.74 s1.48 s, 1.37 s0.37 s, 0.33 s
fsck13.6 s14.0 s12.6 s
gc29.4 s29.9 s70.2 s
store after gc369 MB369 MB295 MB

The first one-file status after the first commit (1.10 s) builds the state index for the first time. Most of what remains in status is listing the tree (about 135 ms), loading the index (about 60 ms) and starting up; commit adds storing and recording with fsync (about 100 ms).