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/fanout16-reasoning.md

A 16-bit fanout in front of the .idx: 4× on the seam, 1.003× on the request — reasoning

Class: reasoning. The measurement behind gunnar_store::fanout::Fanout16 and the decision it produced: built, tested, correct, and off by default, because the request does not move. Measured 2026-08-04 on oden.

What this lane was asked, and what bounded it before it started

.nornir/oid-index-reasoning.md raced a znippy-zoomies static tree against git's .idx and found something better than either: widening git's own fanout from 8 bits to 16 costs ~40 lines and 256 KiB and beats both. Its verdict was "do not adopt the tree; widen the fanout instead", measured in a self-contained microbenchmark over four .idx files.

This lane asks the next question — does that win survive contact with gunnar? — and its own sizing already said the answer was small: the .idx probe is 43% of gunnar.lookup_1m's 722 ns hit, but a request is not a lookup, and two other lanes measured pack_write at 75.7–97.5% of a fetch's CPU (median 88.6%).

1. Where gunnar resolves an oid to a pack offset, and how often

MEASURED, bench/examples/oidlookup-probe.rs (the oidprobe feature counts each call site), all 19 mirrored workspace_nordisk repositories, oden 2026-08-04. One clone and one negotiated fetch 200 commits behind, per repository, summed:

call site who owns the .idx probe clone fetch
PackBitmap::pack_position gunnar (gix_pack::index::File it opened) 75 967 98 775
PackBitmap::bitmap_for_commit gunnar 160 1 796
gix_pack::Find::location_by_oid gix-odb 60 887 27 489
ObjectStore::header gix-odb 27 260 12 236
ObjectStore::read gix-odb 596 5 747
ObjectStore::contains gix-odb 253 3 790
gix_pack::Find::try_find_cached gix-odb 1 211 0
total oid-keyed lookups 166 334 149 833
objects served 60 887 27 489
lookups per object served 2.73 5.45

Two facts decide the shape of everything below.

gix_pack::Find::entry_by_location is called 27 489 times on a fetch too and is excluded from every count here: it is keyed by offset and does no oid search at all.

What that costs, by perf

perf record -F 4999 over the same 19-repository clone+fetch sweep, 8 238 samples, taskset-pinned:

symbol share of cycles
gix_pack::index::access::lookup 0.63%
<gix_pack::index::File>::lookup::<&ObjectId>::{closure#0} 0.11%
gix_odb::…::location_by_oid 0.19%
all oid-keyed index probing ≈ 0.93%

Against the same sweep's 1 651 ms of process CPU that is 15.4 ms over 316 167 lookups — 47 ns each, which is the independent cross-check that matters: the stree lane measured git's bsearch_hash at 59.8 ns on a 12 328-object sha1 index, and eighteen of the nineteen members are smaller than that.

For scale, the same profile: 24.6% inflate_fast, 16.2% longest_match, 13.4% deflate_medium. The pack writer's zlib is fifty times the index.

2. The structure, and where it was put

crates/gunnar-store/src/fanout.rs — 65 536 u32s, a cumulative histogram of each oid's first two bytes, plus a twelve-line bounded bisection over gix_pack::index::File::oid_at_index. It returns the same EntryIndex the .idx's own lookup returns.

Nothing on disk changes. It is derived, in memory, and a caller that drops it loses only time. That is deliberate: a sidecar file needs its own invalidation rule, and a stale oid index answers with a wrong pack offset, which is silent.

Built at open, cached with the bitmap. BitmapCache already re-stats the .bitmap on every call and rebuilds the whole PackBitmap when path, mtime or length move, so the table inherits an invalidation rule already known to be right. Build is 2.4–5.6 ns/oid plus a fixed 65 536-entry prefix sum:

build ns/oid
korp (4 132) 23.1 µs 5.59
nornir (12 328) 29.4 µs 2.39
xl (352 396) 376 µs 1.07
1 000 000 sha256 2.39 ms 2.39

A floor at 4 096 objects. The table is 256 KiB whatever n is. On bifrost (83 objects) that is 3 KiB per object to remove probes from a bisection that is already zero deep — git's 8-bit fanout leaves n/256 entries, so below 512 objects there is nothing left to narrow. Under the floor build returns None and git's own lookup is used; 12 of the 19 members are under it.

2b. Correctness, over the whole corpus, before any clock started

65 737 oids across all 19 repositories, sha1 and sha256, every one agreeing with git verify-pack -v. Two checks per oid, and the second one is the one that earns its cost:

The floor is bypassed here on purpose (Fanout16::build_unfloored): Fanout16::MIN_OBJECTS is an economics decision and correctness is not a property the small packs get to skip because of it — bifrost's 83 objects are exactly where lo == hi on nearly every query.

Both halves were seen red on the real corpus, not only on a fixture:

what was broken what fired
build_unfloored indexes on swapped bytes Skidbladnir: 0049219…: fanout16 says None, .idx says Some(0) — the first repository, the first oid
lookup returns the next entry Skidbladnir: 0049219…: fanout16 says Some(1), .idx says Some(0)

3. What it is worth — the seam

PackBitmap::pack_position over every oid in each pack, release profile (stock x86-64, no target-cpu=native — gunnar ships a portable appliance binary), taskset -c 8, min of 11, shuffled query order.

The shuffle is load-bearing and was wrong first. Walking a pack's oids in .idx order asks a sequence of queries whose slices are adjacent, so almost every probe is already in L1: it measured the 1 000 000-object index as faster per lookup than the 352 396-object one, which is not a property any search has. A fetch asks in graph order, uncorrelated with oid order.

repo objects hit off hit on × miss off miss on ×
nornir 12 328 73.50 16.81 4.37 85.87 9.96 8.62
facett 9 136 66.70 15.58 4.28 79.32 8.89 8.92
holger 6 317 60.05 14.28 4.21 72.25 7.87 9.18
znippy 5 402 57.06 14.03 4.07 69.24 7.56 9.16
dwarves 4 768 54.44 13.41 4.06 67.05 7.31 9.17
workspace_nordisk 4 305 52.32 13.46 3.89 65.30 7.28 8.97
korp 4 132 51.53 13.16 3.92 63.95 7.13 8.97
— under the floor — 83–3 626 13.6–49.2 unchanged 1.00 13.2–62.4 unchanged 1.00

ns/lookup. The off column is not a straw man: it is gix_pack::index::File::lookup, git's algorithm as gitoxide ships it, reached through the same PackBitmap call. Part of the ratio is that gix's version routes every probe through a &dyn Fn(EntryIndex) -> &oid and this one calls oid_at_index directly; the fanout is the rest.

And beyond the corpus, straight through Fanout16::lookup against index::File::lookup (SCALE_IDX=…), same conditions:

index objects hash hit off hit on × miss off miss on ×
nornir 12 328 sha1 70.75 12.04 5.88 82.52 6.52 12.66
xl 352 396 sha1 235.79 72.99 3.23 252.21 78.54 3.21
packed-1000000o-1p-v2 1 000 000 sha256 485.50 165.55 2.93 499.99 171.37 2.92

The stree lane's stock-codegen table, on the same three scales, has fanout16_bsearch at 4.07×, 3.29× and 2.33× on hits. Two independent implementations, two independent harnesses, agreeing to within ~25%. The structure does what that lane said it does.

4. What it is worth — the request, which is the number that decides

One negotiated fetch per corpus repository, 200 commits behind, all 19, both arms in one process in one sitting, arm order alternating by repetition, min of 11, process CPU (RUSAGE_SELF) rather than wall, because oden carried other lanes at load 20–69 throughout.

Two shapes, because the answer depends on who owns the open:

shape off on ratio, across runs within-arm spread
warm — one store per repository, held open (production) 742.3–755.5 ms 737.8–754.4 ms 1.0016 · 1.0017 · 1.0030 · 1.0031 · 1.0061 · 1.0076 1.007–1.052
cold — store reopened per request 755.6–775.5 ms 750.5–767.3 ms 1.0026 · 1.0029 · 1.0036 · 1.0041 · 1.0047 · 1.0054 · 1.0069 · 1.0106 1.013–1.078

Eight full A/B runs, six of which measured both shapes. The effect is 0.16–0.76% in the shape a server runs in, against a within-arm spread of 0.7–5.2%. Every run's ratio is smaller than that run's own noise, so no ratio is claimed for it.

The projection agrees, which is what says the null is a real null and not a disconnected wire: 74 695 of a fetch's 98 775 bitmap lookups fall on repositories over the floor, at ~40 ns saved each, is 2.1 ms out of ~750 ms — 0.28%.

Wire bytes are byte-identical, asserted rather than assumed: 27 489 objects, 56 985 358 bytes and one FNV-1a digest of the whole v2 response — advertisement, acknowledgments, sidebands, packfile and all — identical across both arms, both shapes and all 44 timed sweeps, over all 19 repositories including the sha256 member. And on a clone as well, which is a different arm of select_objects — no excludes, no negotiation, every delta base in the transfer — 60 887 objects, 92 226 528 bytes, one digest, both arms.

The ceiling on this whole area

Fronting every lookup a fetch makes, including the 33.1% inside gix-odb, and with no floor: 149 833 × ~54 ns is 8.1 ms of ~750 ms, 1.07% of a fetch's CPU, of which perhaps 60% is removable. The most this area can ever be worth on the nordisk corpus is well under one percent, and the part that is buildable without forking gix is a third of that.

5. Memory

256 KiB per open pack over the floor, flat, resident, for the life of the PackBitmap:

tables for comparison
the 7 nordisk mirrors over the floor 1.75 MiB 1.25 MiB of .idx on disk for those seven
all 19 mirrors 1.75 MiB 1.79 MiB of .idx on disk for all nineteen
100 open packs over the floor 25 MiB
1 000 250 MiB
10 000 2 500 MiB

On this corpus the tables are 140% of the .idx files they accelerate, and 98% of every .idx in the constellation put together. A forge holding a thousand active repositories pays a quarter of a gigabyte of anonymous memory — which does not page out, unlike the mapped .idx it fronts — for under a percent of a fetch. That is the argument against, and it is a stronger one than the CPU.

6. What was seen red, and why each fired

Seven tests in crates/gunnar-store/tests/fanout16_finds_what_git_finds.rs. The bar is stock git, not gunnar's reader: git verify-pack -v's own oid → offset table, compared against pack_offset_at_index(fanout.lookup(oid)) for every object in the pack, both hash kinds. That is the check the stree lane's reader bug was found by, and gunnar's reader appears on one side of it only.

red what was broken what fired, and why
build indexes on swapped bytes the shipping Fanout16::build 4 of 7 red. 000494a2… is in the pack and was not found — a present object became a miss, which is the failure a wrong slice produces, not a wrong index
table shifted one bucket build_red("shift") equivalent red with fanout16 says None, .idx says Some(0)
table on swapped bytes build_red("swap") same, and it matters: `(b1<<8)
lookup displaced one entry the git verify-pack comparison itself 0 of 4 200 objects kept git's offset, so the outside check cannot pass on a wrong index
entry_index ignores the fanout it built PackBitmap all 7 stayed GREEN. See below

That last row is the finding inside the finding. Both searches return the same entry index by definition, so a PackBitmap that builds a 256 KiB table and never reads it is invisible to every wire byte, object count, position and fidelity check in the tree — and would have made this lane's end-to-end measurement report a null result for a disconnected wire, which is exactly the answer it was expecting. The guard that closes it is a probe counter on Fanout16 asserting pack_position over n objects went through the table n times; it is #[cfg(debug_assertions)], because a relaxed fetch_add is ~1 ns against a 15 ns lookup, and the release build's wiring is instead proven by the seam table above moving 4×.

7. Three wrappers that turn the bitmap OFF, and two of them were still live

ObjectStore::bitmap's trait default is None, so any wrapper that forwards every other method and omits this one does not fail to observe the bitmap — it disables it. Another lane found and fixed this in gunnar_bench::stages::StageCounting on 2026-08-04 (468db46). Two more instances were still in the tree and are fixed here:

Nothing could catch either: the bitmap path is defined to select the same object set as the walk, so the wire bytes agree across all 19 repositories, which is the fidelity check both instruments rest on.

The decision

Keep the structure. Do not turn it on.

GUNNAR_FANOUT16=on builds it; the default does not. That is off by design, not by omission — the distinction ObjectStore::packed's own documentation draws — and the reasons are recorded rather than assumed:

Turning it on is one environment variable, so the day one of the three conditions below holds, the measurement is one command and not a day:

  1. a repository past ~350 000 objects. At xl the baseline is 236 ns and at a million 486 ns, against 12 328's 71 — the seam grows with log n while the rest of a request does not.
  2. a miss-dominated workload. Misses are where the ratio is 8.6–9.2×, and negotiation against a divergent client is nothing but misses.
  3. a batch seam. P-003. The 33.1% of lookups inside gix-odb are not frontable at all today, and the 66.9% that are, are answered one at a time.

The larger prize in this area was never the index. It is the 88.6% of a fetch that is pack_write, and within the read side the per-call overhead P-012 names — 57% of a hit and 88% of a miss in gunnar.lookup_1m is neither the .idx nor anything a wider fanout can reach.

Re-taking it

Everything above is one command each, which is the point of committing the instruments rather than only the numbers.

cd bench
CARGO_TARGET_DIR=/home/rickard/scratch/cargo/gunnar-fanout-bench   cargo build --release --example fanout16-ab --example oidlookup-probe
cd ..
B=/home/rickard/scratch/cargo/gunnar-fanout-bench/release/examples

# §1, the call-site census. Needs `gunnar-pack/oidprobe`, which `bench/` turns on.
taskset -c 8 $B/oidlookup-probe 200

# §3 the seam, §4 the request, §5 the memory. Both arms, interleaved.
taskset -c 8 $B/fanout16-ab

# §3's scale rows, over indexes the corpus does not contain.
F=/home/rickard/scratch/gunnar-bench-fixtures
SCALE_IDX="$(ls $F/h2h-linear-sha1-20480c-1024f-16k/objects/pack/*.idx):$(ls $F/packed-1000000o-1p-v2/objects/pack/*.idx)" taskset -c 8 $B/fanout16-ab

# §6, the guards, including the four reds.
cargo test -p gunnar-store --features fs,conformance   --test fanout16_finds_what_git_finds

A probe rather than a persisted nornir bench run arm, deliberately and for the reason bench/Cargo.toml already gives readside-probe, stage-probe and stomach-probe: this answers how many and whether at all, which is a diagnostic a human reads once, and the answer is a null. A persisted arm over a structure that is off by default would record the baseline against itself. If one of the three reopening conditions above ever holds, the arm to add is the A/B — seam_hit_x, e2e_warm_x — not the on-path alone.

What was not determined