Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/storage-git-gix/parked-gix/.nornir/oid-index-reasoning.md

oid → pack offset: does a ragnar static tree beat git's .idx? — reasoning

Class: reasoning. The measurement behind gunnar.oid_index, and the decision it produced: do not adopt znippy-zoomies' stree for oid → pack offset. Widen the fanout instead. Measured 2026-08-04 on oden.

The question, and why the usual framing of it is wrong

znippy-zoomies ships stree/stree32, an implicit S(+)-tree derived from Ragnar Groot Koerkamp's static-search-tree, advertised at "up to ~40× vs binary search" and getting faster with many keys requested at once (.nornir/stree-reasoning.md in that repo). gunnar's hottest structural question is oid → pack offset. So: adopt it?

The 40× is against the wrong opponent. git's .idx v2 is not a binary tree. It is a sorted oid array behind a 256-entry fanout indexed by the oid's first byte, binary-searched from inside a ~1/256 slice, on an mmap — one radix step and no syscall before the search even starts. Against that, most of the tree's structural advantage is already spent, and the only honest measurement is against packfile.c's bsearch_hash as git actually writes it.

There is a second wrong-opponent trap one level down, and it is the one that decided this. If the tree's advantage is "narrow the slice before you search it", then git's fanout is the version of that idea git already ships, and it can be widened without a new data structure. So the suite also races a 65 536-entry fanout on the oid's first two bytes (fanout16_bsearch, ~40 lines, 256 KiB flat per pack) and that same fanout with a u64-prefix compare and 16 searches in flight (fanout16_pipe16).

Which stree variant, and why there is a variant question at all

Neither tree can hold an oid. STree32's key is a u32, STree64's is an i64, and an oid is 160 or 256 bits. Every stree over oids is therefore a tree over a prefix, plus a verification of the full oid at the position the tree lands on — which is the last step the binary search does anyway, so both structures answer the same question and return the same index into the same sorted table.

The prefix width is then the whole design choice, and it is a collision question. A priori it favours 64 bits: expected colliding pairs are n²/2^(w+1), so ~14.5 pairs at 352 396 oids for a 32-bit prefix against ~3·10⁻⁹ for a 64-bit one. The measurement went the other way. Real collision counts were 10 (of 352 396) and 129 (of 1 000 000) — the expected extra comparisons per query are n/2^32, i.e. 2·10⁻⁴ at a million objects, which is nothing — and STree32 (B=16, log₁₇ levels, 4.25 B/oid) beat STree64 (B=8, log₉, 9.00 B/oid) on every cell of the matrix, on hits and misses, at every corpus size, while costing half the memory. So the measured answer is STree32 over the top 4 bytes, and the a-priori answer was wrong. Both are reported.

What was measured

Nine arms over four real .idx files, taskset-pinned to one core, min-of-N with the within-arm max/min spread published beside every number.

corpus oids hash .idx
tunnr.git 952 sha256 38 KiB
nornir.git 12 328 sha1 338 KiB
xl (h2h-linear-sha1-20480c-1024f-16k) 352 396 sha1 9.41 MiB
packed-1000000o-1p-v2 1 000 000 sha256 38.1 MiB

Plus the 19 mirrored workspace_nordisk repositories (65 737 oids, 83 to 12 328 each) for the rebuild economics.

The corpora are real .idx files and the oids are real hashes. No id in this measurement was invented; the two large fixtures are generated histories, so their oids are genuine sha1/sha256 of genuine objects, which is what makes their distribution the same as any other repository's.

What the instrument had to survive first

The result

Nanoseconds per lookup, batch 1 000, stock x86-64 codegen (what gunnar ships), min of 11. The within-arm max/min band over those 11 was ≤ 1.09 on every miss column and on the 1 000 000-oid hit column; on the two smaller hit columns it reached 1.26–1.30 for the stree arms and 1.61 for fanout16_pipe16, so no ratio below ~1.3 is claimed there and every such cell is called a tie below.

12 328 sha1 352 396 sha1 1 000 000 sha256
hit miss hit miss hit miss
idx_bsearch — git 59.8 67.8 140.6 146.0 309.5 316.6
fanout16_bsearch 14.7 7.2 42.8 42.9 132.9 127.8
fanout16_pipe16 23.2 12.2 38.5 31.5 62.7 47.6
stree64_batch16 32.5 24.4 49.8 28.4 90.3 35.9
stree32_batch16 29.4 21.6 40.2 25.1 76.3 26.8

Against git, the tree wins and wins clearly: 2.0–4.1× on hits and 3.1–11.8× on misses. Rickard's claim survives contact with the right baseline in the sense that mattered — it is not 40×, but it is real.

Against a wider fanout, it does not win anywhere gunnar lives. At real repository size (12 328 oids, and eighteen of the nineteen nordisk repos are smaller) a forty-line 16-bit fanout is 2.0× the tree on hits and 3.0× on misses, and the tree's batch machinery is pure overhead. At 352 396 it is a tie on hits and the tree is 1.26× on misses. Only at a million objects does the tree win anything decisive, and only on the miss path — 1.78× stock, 3.6× native, both far outside the band. On hits at a million it loses, by 1.22× stock and 1.06× native; the second of those is inside the noise band, so the honest reading of the hit path at every size is the fanout is at least as good as the tree.

The batch axis, which is the claim that was actually being tested

"Gets faster with many ids at once" is true, and it saturates and then reverses. stree32_batch16 on xl, hits, ns/lookup:

batch 1 10 100 1 000 10 000 100 000 200 000
ns 110.4 92.1 42.1 40.2 42.3 51.2 55.2

2.7× from batch 1 to batch 1 000, flat to 10 000, and 37 % worse by 200 000 because the query and result arrays stop fitting beside the tree. The right batch is ~10³, not "as many as you have". gunnar-pack's vouching closure and select_objects both chunk naturally at that size, so this is not a constraint — but "bigger is better" is wrong and would have been designed in.

Misses are where the tree is structurally different, and it is not a small thing

A miss costs git exactly what a hit costs (316.6 vs 309.5 at a million): the binary search runs to exhaustion either way. The tree refutes a miss at the prefix level, without touching the oid table at all — 26.8 ns against a 38 MiB table it never reads. That is a genuine structural property and it is the negotiation workload, where every have from every client is a question about an object the server may not hold.

It is also the only property the tree has that the fanout cannot copy.

The codegen dependency, which is load-bearing and was nearly missed

The same code, same corpus, same core, with only -C target-cpu=native removed:

arm native stock x86-64 penalty
idx_bsearch hit / miss 310.6 / 309.8 309.5 / 316.6 1.00× / 1.02×
fanout16_pipe16 hit / miss 61.5 / 47.0 62.7 / 47.6 1.02× / 1.01×
stree32_batch16 hit / miss 65.1 / 13.0 76.3 / 26.8 1.17× / 2.06×
stree64_point hit / miss 159.3 / 44.9 237.3 / 80.7 1.49× / 1.80×

The baselines are codegen-insensitive and the tree is not. Its miss advantage halves on a portable build. MEASURED; the mechanism — that the AVX2 count_lt inlines into the batch driver only when the whole crate is compiled for the target — is INFERRED and was not confirmed by reading codegen. gunnar ships a distributable appliance binary, so the stock column is the one that applies, and the native column is what a -march=native rebuild on the serving host would buy back.

Sizing it against gunnar's real lookup cost, which is what settles it

gunnar.lookup_1m measures the same 1 000 000-object sha256 fixture through FsStore, and on 2026-08-03 reported lookups_per_sec_hit 1 384 680 (722 ns) and lookups_per_sec_miss 370 068 (2 702 ns). The .idx probe inside those is 310 ns and 317 ns. So the index structure is 43 % of a hit and 12 % of a miss; the rest is gix-odb handle, snapshot and dispatch (P-012).

Holding that overhead constant and swapping only the structure:

hit miss
today 722 ns 2 702 ns
with fanout16_pipe16 475 ns (1.52×) 2 433 ns (1.11×)
with stree32_batch16 488 ns (1.48×) 2 413 ns (1.12×)

The two candidates are within 3 % of each other on gunnar's actual call path. A 1.78× on the miss structure is 1 % end to end, because the structure is one-eighth of a miss. The larger win in this area is not the index at all — it is the 57 % of a hit and 88 % of a miss that is per-call overhead.

And a clone does not look anything up

pack-objects orders the pack the way a client consumes it, so a clone copies a contiguous byte range — the xl corpus's 352 396 oids are written, not resolved. Only fetch scatters: every have, every selected object, and every delta base against client_has in write_pack_copy_thin's vouching closure. So even the full 1.5× above lands on one arm of one operation, and the arm it lands on is the one this repository already serves fastest.

Per-repo trees, not one global tree

Rickard's instinct — "would that mean it must be regenerated all the time? it's better to have many small ragnars" — is right, and here is the number rather than the agreement. Build is a linear pack over an already-sorted array; measured between 3.3 and 5.3 ns/oid for STree32 across three decades of n (the extrapolations below use the pessimistic 5), and 4.25 B/oid resident.

build resident invalidated by
per-repo, all 19 nordisk mirrors (65 737 oids) 0.22 ms total 275 KiB total a push to that repo
per-repo, xl-sized repo (352 396) 1.8 ms 1.43 MiB a push to that repo
one global tree, 100 × xl (35.2 M oids) ~176 ms † ~143 MiB † a push to any of the 100

† EXTRAPOLATED from the measured 5 ns/oid and 4.25 B/oid; no 35 M-key build was run. The three measured points (12 328 / 352 396 / 1 000 000) are linear to within 20 %.

A global tree pays ~176 ms and re-materialises 143 MiB on every push to any repository in the forge; per-repo trees pay 1.8 ms and only the repository that changed pays it. That is ~100× less rebuild work per push, and it is local. Many small ragnars, confirmed.

There is one legitimate global index — "which repository holds this oid", which is how forges do cross-repo dedup and alternates. That is a different feature with a different key and a different payload, and it is not this one.

The decision

Do not adopt stree for oid → pack offset. If this area is worked, widen the fanout: fanout16_bsearch is forty lines, 256 KiB per pack, no dependency, no codegen requirement, and it is 2.0–4.1× git at every corpus size gunnar actually serves, beating the tree outright below ~350 000 objects. fanout16_pipe16 adds the pipeline where a caller already has its oids in bulk.

Revisit only if all three hold at once: a repository beyond ~10⁶ objects, a miss-dominated workload (deep negotiation against a divergent client), and a serving binary built for its host. Until then the tree is a dependency, 4.25 B per oid, and a 3 % difference.

What was not determined

The follow-up, which was run and answered

.nornir/fanout16-reasoning.md, 2026-08-04. The recommendation above — widen the fanout — was built (gunnar_store::fanout::Fanout16), held to git verify-pack -v object by object, and measured through gunnar rather than through a microbenchmark. It confirms the structure and declines to ship it:

So Fanout16 exists, is correct, and is off unless GUNNAR_FANOUT16=on.

Not the same question as __gunnar_oid__

A backend lane declined to emit a __gunnar_oid__ section, and that verdict was about the schema — that section maps oid → archive entry row, and a pack tier has only two entries, so the column has nothing to address. This note is about oid → pack offset, which was untested until now. The two conclusions happen to point the same way and they are not the same finding; neither supports the other.


Follow-up: ragnar on the haves (2026-08-04, same day)

The question the note above left open, asked properly. Same answer, and now with the end-to-end number attached: the structure is not the term that matters. Arm: gunnar.have_lookup.

Why this shape was worth a second measurement

Everything above says the tree loses to a wider fanout — except on misses, where it refutes at the 32-bit prefix without reading the oid table. A fetch's negotiation is a batch membership test, and a divergent client's haves are mostly absent. That is the tree's best case and it is a real workload, so it got its own arm rather than an inference.

The shape, measured off real fetches

18 mirrored workspace_nordisk repositories, real git fetch over file://, haves taken off the wire with GIT_TRACE_PACKET, each one asked of the server with git cat-file --batch-check. The client is made genuinely behind — reset, remote-tracking ref dropped, reflog expired, gc --prune=now — because a client that merely reset still holds every object, git says "up to date", and the trace contains no have at all. That is what the first version of this script measured: zero.

client shape rounds round sizes hit rate
1 / 10 / 100 / 400 behind, no local work 1 16 100.0 %
10 behind + 1 local commit 1 16 93.8 %
10 behind + 10 local 1 16 37.5 %
10 behind + 40 local 2 16, 32 16.7 %
10 behind + 200 local 4 16, 32, 64, ~127 16.2 %

Two corrections to the sizing this arm was commissioned with, both of which lower the ceiling.

And the case that dominates real traffic — a client merely behind, at every depth from 1 to 400 — is 100 % hits in one round of 16, which is the mix the tree is worst at.

The probe

nornir's mirror, 12 455 oids, one pack, min of 15, taskset-pinned. ns per lookup:

shape haves hit idx_bsearch fanout16 stree32_batch16
behind16 16 100 % 22.86 7.41 27.47
diverge16 16 37.5 % 26.13 4.79 24.03
diverge32 32 25 % 26.93 4.35 22.90
diverge128 128 16.2 % 26.98 4.26 21.44
cap256 (unobserved) 256 0 % 27.71 4.36 20.69

The band on this table is 1.646, so the only claim it supports is the large one: the 16-bit fanout is 3.7–5.3× the tree on every shape, including the all-miss one the tree was supposed to own. stree32 against git's binary search runs 0.83–1.34×, entirely inside the band, and is not claimed.

The tree does behave as predicted relatively — it improves as the mix gets missier (27.47 → 20.69 ns from all-hit to all-miss) while git's search gets slightly worse (22.86 → 27.71). It just starts too far behind to matter, because at real repository size the whole index is 338 KiB and a 65 536-entry fanout lands on ~0.2 oids per bucket.

End to end, which is the half that was actually commissioned

The same fetch served out of a private mirror four times per repetition — contains answered by gunnar-as-it-ships, by git's algorithm over the mapped .idx, by the 16-bit fanout, and by the tree — interleaved, nine repetitions, gunnar-stage decomposing each one. Two client shapes, because they have very different denominators and quoting one would be choosing the answer.

behind (real ancestors, 4 objects sent) diverge (nothing cut, 12 216 sent)
whole-request CPU, as shipped 2 349 µs 121 986 µs
negotiate stage, wall, as shipped 184.0 µs 641.4 µs
negotiate, git's algorithm on the .idx 183.0 µs 77.7 µs
negotiate, 16-bit fanout 182.2 µs 46.5 µs
negotiate, stree32 183.7 µs 52.7 µs
contains calls 144 257
whole-fetch CPU, stree32 ÷ as-shipped 0.991× 1.008×
noise band on that ratio 1.149 1.040

Both end-to-end ratios are inside their own noise bands. The end-to-end effect of putting ragnar on the haves is unmeasurable, and that is the result.

The three things this found that the probe could not

1. The dominant term is not the index, it is gix-odb's miss path. On the all-miss shape, gunnar as it ships spends 641.4 µs on 257 contains calls — 2.50 µs each — where the same work read straight off the same .idx is 0.302 µs and a 16-bit fanout is 0.181 µs. That is 13.8× between the shipping path and the cheapest candidate, and 1.13× between the two candidates. On the all-hit shape the gap disappears (184.0 vs 182.2 µs), which is the signature of RefreshMode::AfterAllIndicesLoaded — P-012 measured the same asymmetry from the other side (866 518 misses/s with RefreshMode::Never against 78 603 without). The lever here is not a new structure; it is the refresh a miss triggers, and P-012 already names it.

2. The warm-loop probe overstates all three by about 10×. The probe reports 22.86 ns for git's search; in a real negotiation, where each structure is touched once rather than 20 000 times, the same search costs 302 ns. Every ratio narrows with it: the fanout's advantage over the tree drops from 3.7× to 1.13×. A lookup microbenchmark measures the loop, not the negotiation, and both numbers are published here for exactly that reason.

3. Even a 13.8× on the negotiate stage cannot be seen in a fetch, because on the shape where it exists the stage is 641 µs of a 121 986 µs request — 0.53 % — and on the shape where the stage is a real 7.8 % of the request, the haves are all hits and there is nothing to win. The two conditions do not co-occur.

Recommendation

Do not put ragnar on the haves. It loses the probe to a forty-line fanout at every measured mix, its end-to-end effect is inside the noise band on both client shapes, and the term that actually costs — 2.50 µs per missing have through gix-odb — is not an index-structure problem.

If this path is worked, the order is: RefreshMode on the miss path (P-012, 13.8× available and already diagnosed), then a wider fanout if anything, then nothing. The tree is a dependency and 4.25 B per oid for a difference of 1.13× on 0.53 % of one arm of one operation.

What was seen red

What could not be determined