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