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/performance-design.md

gunnar — performance design

Opened 2026-08-03, against master at 704b4e0 ("Build upstream-bound code as donation-shaped crates"). Companion to .nornir/stinky.md, which is owned by another agent: nothing here edits that file. Entries use the same block format with ids P-001…, so they merge into it unchanged. Where a finding overlaps an existing S- entry it says so and does not restate it.

Load target. One million users on a single 64-core server, Codeberg-class: millions of repositories, thousands of concurrent clones, repositories with millions of objects.


What is MEASURED here, and on what

This is the first time any part of gunnar's cost model has been measured. Four numbers below are measured on oden; everything else is read from source or is arithmetic over measured components, and is labelled.

Machine, and its limits as a proxy for the target. oden is an AMD Ryzen Threadripper PRO 3975WX, nproc = 32, sha_ni / avx2 / bmi2 present. The target is a 64-core server. oden is half the target and every scaling figure below stops at 32 threads. The 64-core behaviour is extrapolated and the extrapolation is not trusted, because the measured efficiency curve is already falling at 32 (63%).

Method. A standalone probe crate at /home/rickard/scratch/cargo/gunnar-perf-probe/, depending on the exact crate versions gunnar's Cargo.lock resolves (gix-hash 0.26.0, flate2 1.1.9, zlib-rs 0.6.6, sha1 0.10.7, sha1-checked 0.10.0), --release, opt-level = 3. Corpus: real .rs files from this tree, git-blob-shaped text: mean 11.3 KB (small corpus, 145 files, 1.57 MiB) and mean 19.2 KB (large corpus, 4000 files, 73.3 MiB). This is a probe, not the bench suite. It is throwaway; P-015 proposes the real, historized one.

Noise, stated up front. Within a single process, two runs of the identical miniz_oxide deflate measured 41.0 and 51.5 MiB/s, a 26% spread. The zlib-rs runs of the same operation measured 85.8 and 85.8. Every ratio below that involves miniz is therefore given as a range, and no ratio here is quotable as a bench result until P-015 re-measures it under the house harness with warmups and best-of-N.

Measurement 1 — SHA-1 (MEASURED 2026-08-03, oden)

hasher MiB/s note
gix_hash::hasher(Kind::Sha1) 353.5 / 364.6 sha1_checked::Sha1, UBC collision detection ON
sha1::Sha1 (RustCrypto) 1778.9 / 1798.0 SHA-NI hardware path
sha1_checked with detection off 1948.1 / 1702.3 falls through to sha1::compress
gix_hash::hasher(Kind::Sha256) 1601.5 / 1840.8 sha2 0.11, SHA-NI

gix's SHA-1 runs at ~1/5 of the SHA-1 this CPU can do (4.9×), and — the fact worth sitting with — at ~1/5 the speed of its own SHA-256. A sha1 repository hashes roughly 4.7× slower than a sha256 repository on the same box. DEFAULT_HASH_KIND is already Sha256 (crates/gunnar-store/src/object.rs:13), so new repositories get the fast path. Every mirrored or imported repository, which is all of an existing forge's content, gets the slow one.

Root cause, READ: gix-hash-0.26.0/src/hasher.rs:36 builds sha1_checked::Builder::default().safe_hash(false).build(), and sha1-checked-0.10.0/src/lib.rs:186-190 branches: with detection on it calls its own pure-Rust compress::compress; only with detection off does it reach sha1::compress, which is the SHA-NI path (sha1-0.10.7/src/compress/x86.rs:100, cpufeatures::new!(shani_cpuid, "sha", …)). This matches git's own default (sha1dc), so it is not a gitoxide defect. What it is, is an unpriced cost that becomes the entire bottleneck once P-001 lands. See P-008.

Measurement 2 — zlib backend (MEASURED 2026-08-03, oden)

Identical flate2 API, identical corpus, one feature flag apart.

operation miniz_oxide (default) zlib-rs ratio output size
deflate L6 41.0 (and 51.5) MiB/s 85.8 MiB/s 1.7–2.1× ratio 3.28× — identical
deflate L1 93.9 MiB/s 257.5 MiB/s 2.7× 2.63× vs 2.34× (zlib-rs 11% larger)
inflate 308.4 MiB/s 554.6 MiB/s 1.8× —

At L6 the compression ratio is byte-for-byte identical (3.28×) and the throughput roughly doubles. zlib-rs 0.6.6 is already linked into gunnar via gix-zlib → zlib-rs (Cargo.lock:2683-2690), so this costs zero new dependencies and, unlike zlib-ng, no C toolchain. See P-007.

Measurement 3 — does per-object deflate scale? (MEASURED 2026-08-03, oden)

4000 objects, 73.3 MiB, zlib-rs L6, static stride partition across std::thread, best of 3.

threads MiB/s speedup efficiency
1 79.1 1.0× 100%
2 150.2 1.9× 95%
4 280.6 3.5× 89%
8 559.7 7.1× 88%
16 951.2 12.0× 75%
24 1258.0 15.9× 66%
32 1602.8 20.3× 63%
48 1608.5 20.3× 42%
64 1517.6 19.2× 30%

20.3× on 32 hardware threads, and a hard plateau at the hardware thread count. Oversubscribing to 48 or 64 buys nothing and 64 is measurably worse than 32. Efficiency is already down to 63% at 32, which says the workload is partly memory-bandwidth-bound, not purely compute-bound. Do not extrapolate 40× at 64 cores from this curve.

Combined with Measurement 2: today's serial miniz path runs at ~38 MiB/s (derived: 79.1 ÷ 2.09); a parallel zlib-rs path measured 1602.8. That is the ~42× ceiling on the deflate component, and P-001 argues most of that work should not happen at all.

Measurement 4 — the allocation micro-optimisation buys nothing (NEGATIVE RESULT)

crates/gunnar-pack/src/write.rs:121 allocates a fresh Vec and a fresh ZlibEncoder per object. Replacing it with a reused, cleared scratch buffer measured 85.8 → 86.1 MiB/s (zlib-rs) and 51.5 → 51.5 MiB/s (miniz): no difference beyond noise.

Deflate dominates so completely that the allocation is invisible. S-015 ("Two heap allocations per packed object") is correct as an observation and its memory-pressure argument stands, but it should not be sold as a throughput fix — measured, it is not one. Recorded here so the next agent does not spend a day on it expecting a number.


The CPU budget — where a served byte actually goes

Derived arithmetic over the measurements above, per 1 MiB of raw object content served (deflates to 0.305 MiB on the wire at the measured 3.28× ratio), one core. This is component arithmetic, not an end-to-end clone measurement. It excludes the graph walk, object lookup, syscalls and the network, which is the whole point of the last row.

stage today +P-007 zlib-rs +P-001 pack-copy +P-008 SHA-NI trailer
inflate object out of store 3.24 ms 1.80 ms — —
deflate into the pack 24.39 ms 11.66 ms — —
copy already-deflated bytes — — ~0.05 ms ~0.05 ms
trailer SHA-1 over 0.305 MiB 0.86 ms 0.86 ms 0.86 ms 0.17 ms
total (compression + hash only) 28.49 ms 14.32 ms 0.91 ms 0.22 ms
vs today 1.0× 2.0× 31× 129×

Three things fall out of this table, and they set the entire build order.

  1. Today, deflate is 86% of the compression+hash budget. Everything else is rounding.
  2. P-001 does not make deflate faster — it deletes it. The bytes are already deflated, sitting in the pack the client pushed. Copying them is the whole job.
  3. The SHA-1 finding is worth 3% today and ~95% after P-001. Before pack-copy, fixing the hash is a rounding error and would be a bad use of a day. After it, the trailer hash is nearly the entire remaining cost. P-008 is sequenced behind P-001 deliberately, and its value is conditional on it.

Two columns of that table are now real (2026-08-03). P-007 and P-008 both landed and were re-measured end-to-end through PackWriter on the same frozen 1.70 MiB corpus: 47.4 MiB/s → 90.8 MiB/s, a 1.92× whole-writer speedup at level 6, against the 2.0× the table's first two columns predict. The table's arithmetic held. What it could not predict is that with deflate removed — level 0, the shape P-001 produces — the writer goes 358 → 1442 MiB/s, which is the "~95% of what remains" claim confirmed rather than derived.

And the honest bound: once the table's last column is real, serving is no longer limited by compression at all. It is limited by the graph walk (P-010) and by object lookup (P-003). Neither has ever been measured. No end-to-end clone speedup is claimed here by anyone.

All four columns are real as of 2026-08-03, and the bound above arrived exactly as written. P-001 landed and was measured on both sides: 9.9× on a corpus with no deltas to reuse (clone_large, byte-identical output, 3197.72 → 322.59 ms) and 18.3× fewer bytes with 13.0× less server CPU on one with history (vs_git_clone against stock git). The table's 31× was arithmetic over the compression+hash budget alone, and a real clone carries a graph walk and an object lookup per object that the table deliberately excluded. That is why the measured figure is a third of the derived one, and why the derived one was not wrong.

What is left is the sentence above coming true: gunnar now emits the same bytes as git upload-pack, for 16× the server CPU. That residue is P-010 and P-018, and it is now the entire serve-path gap.

The walk has now been measured (2026-08-03) and the first thing it said was that the question was mis-posed. P-010 framed the walk as serial where it should be parallel. Profiled, the largest single item in it was not serialism at all: every commit in a clone was read from the store and decoded twice, once for its parents and once again for the root tree the first decode had already parsed and discarded. Removing that, plus the full-object commit parse and the per-object buffer churn, is 1.295× on walk_1m and 1.025× on clone_large — instructions retired, interleaved, both sides. It is also 3.006 G instructions genuinely deleted, where a perfect 32-way fan-out would have deleted none. See P-010.


The parallelism answer — and the trap in it

gunnar has no parallelism anywhere (S-011, grep -c gatling Cargo.lock → 0). The reflex is "fan everything out with gatling". That reflex is wrong for a forge, and the reason is structural.

READ, from znippy-zoomies/gatling/src/gatling_forkjoin.rs (module doc :19-21): "Pure std::thread::scope; no global pool, no rayon." Every entry point opens a fresh scope, spawns n_workers OS threads, and joins them before returning. There is no pool object, no global runtime, and no reentrancy detection: nesting compiles fine and silently spawns W² threads. nornir's own comment says it plainly (nornir/src/knowledge/symbols.rs:317-318): "Call TOP-LEVEL only — it IS the pool; nesting it under another pool oversubscribes."

A forge is thousands of small concurrent requests, not one big batch job. Wrapping per-request pack generation in gatling_map_balanced at 1000 concurrent clones means 1000 × 64 = 64,000 OS threads, plus 1000 thread-scope spawn/join storms per second. Measurement 3 already shows the box gets slower past 32. That is P-004, and it is a blocker precisely because the obvious fix causes it.

The correct split:

workload shape answer
serving one clone thousands of small, concurrent request concurrency, not intra-request fan-out. Bounded admission (S-008), one core's worth of work per transfer.
serving one clone on an idle box one large job intra-request fan-out, but only under an admission gate that knows the box is idle (P-004)
repack() / gc one big batch, offline gatling_map_balanced — the textbook case (P-011)
building __gunnar_oid__ / __gunnar_graph__ one big batch, offline gatling_map_balanced / gatling_scanlines (P-011)
closure walk on a large fetch frontier expansion gatling_bfs, level-synchronous (P-010)
connectivity check on a large push same walk shares P-010's code (LAW 5)
LFS chunk hashing one job, big object gatling_map_balanced over chunks (P-017)

The aggregate-throughput point, stated bluntly. At saturation, parallelising one clone across 32 cores does not raise clones/second at all. It is the same total work, rearranged. It buys latency and tail latency, which matter, but they are not throughput. The items that genuinely reduce total work are P-001 (delete the compression), P-007 (halve what is left), P-009 (send fewer bytes) and P-008 (hash faster). Rank accordingly, and note that this inverts the order a naive read of S-011 would suggest.

The gatling call shape, for whoever writes it

// From tokio: gatling BLOCKS. It must never be called on a runtime thread.
let entries = tokio::task::spawn_blocking(move || {
    // `batch`: aim for ~4 units per worker, never 1 on cheap units — the shared
    // AtomicUsize cursor is a measured 2x regression at batch=1
    // (znippy-zoomies/src/stree.rs:219-227).
    gatling_map_balanced(
        &counts,                    // &[T]
        0,                          // n_workers: 0 => one per core
        BATCH,                      // claim size per fetch_add
        |c| c.uncompressed_size,    // size_of: runs SERIALLY on the caller thread — keep O(1)
        |_idx, c| -> Result<Entry> {// panics LEAK the output Vec: return Result, never panic
            encode_one(c)
        },
    )
}).await?;

Four constraints that are not obvious and are all READ from source or house comments:


Summary

id title severity status
P-001 Every served object is inflated and re-deflated, discarding the client's own delta compression blocker DONE 2026-08-03
P-002 gix-pack's parallel feature is never declared — the appliance build silently uses the serial code path blocker DONE
P-003 ObjectStore has no batch seam, so the stree batch lookup §4.1 is built around can never be reached blocker TODO
P-004 gatling has no shared pool; per-request fan-out at forge concurrency spawns tens of thousands of threads blocker DECIDED — serving tier stays serial; amended 2026-08-05: was 3 OS threads per transfer, now 1 (5be4644)
P-005 The znippy cold tier forecloses pack-copy and re-compresses every git object at OpenZL level 19 blocker TODO
P-006 znippy requires a C toolchain, cmake and network at build time — §4's "no C toolchain" claim is false blocker TODO
P-007 flate2 resolves to miniz_oxide while zlib-rs sits linked and idle — 1.7–2.1× for one line scale DONE 2026-08-03
P-008 The pack trailer pays SHA-1 collision detection it cannot benefit from — 4.9× on the hash scale DONE 2026-08-03
P-009 gitoxide has no delta encoder at all; what that costs and what pack-copy recovers scale WONTFIX 2026-08-03: pack-copy recovers 100% of a clone and 97.6% of a fetch
P-010 The closure walk is one serial thread and is the bottleneck the moment P-001 lands scale PARTLY DONE 2026-08-03: every commit was decoded twice; removing that and the full-object parse is 1.295× on walk_1m, 1.025× on clone_large, measured both sides. The gatling_bfs half is not landed and is argued against on the same measurement
P-011 repack(), gc and index building are the true gatling batch jobs, and none exist scale PARTLY MEASURED 2026-08-03: repack() exists, already copies and already fans out; the gatling recommendation for it is withdrawn
P-012 Object lookup is O(packs) on a miss, and every thread carries its own unshared cache scale DONE 2026-08-03
P-013 The iceberg backend cannot be multi-process — skade's RedbCatalog takes an exclusive file lock scale TODO
P-014 znippy has no writer lock of any kind; two concurrent appends silently corrupt the archive correctness TODO
P-015 No bench harness exists; the proposed nornir-bench suite and its red conditions correctness TODO
P-016 znippy's incompressible list is extension-only, and git objects have no extension smell TODO
P-017 LFS: chunking, and whether gatling's balanced map fits it smell TODO
P-018 The gix output pipeline's serial tail — sort, reorder, hash — is one thread by construction smell TODO

Counts: 6 blocker · 7 scale · 2 correctness · 3 smell, 18 total. 5 DONE (P-001, P-007, P-008, P-012, P-015), 1 PARTLY DONE (P-010 — the redundant-work half landed and measured; the parallel half withdrawn on the same measurement), 1 DECIDED (P-004, the serving tier stays serial on purpose), 1 WONTFIX (P-009, on the measurement it asked for), 10 TODO.

Build order — ranked by expected win ÷ effort

Not a wish list. Each step's value depends on the ones above it, and that is why the order is not the severity order.

# id why here effort win
1 P-007 DONE 2026-08-03 one line in Cargo.toml, zero new crates, no C toolchain ~1 h 1.70–1.94× measured after landing (ratio moves ≤0.9%, not 0%)
2 P-015 nothing below can be honestly claimed without it; LAW 2 needs a red to point at ~2 d enables every other number
3 P-001 DONE 2026-08-03 deletes 86% of serve CPU and recovers delta compression for free landed at 6e65beb 18.3× fewer bytes and 13.0× less server CPU against stock git; 9.9× on a corpus with no deltas to reuse, measured both sides
4 P-002 one feature flag; silent-serial is a S-011-class bug hiding in the build config ~2 h unknown until measured — that is the point
5 P-008 DONE 2026-08-03 done early, out of order: it is a 60-line trailer-only change, not the ~1 d this assumed ~2 h +6% on a pack write today, 4.0× with deflate removed, measured after landing
6 P-010 PARTLY DONE 2026-08-03 moved up because gunnar emits git's bytes for 16.2× git's server CPU. The walk was profiled: the top item was not parallelism, it was that every commit was read and decoded twice ~1 d of the ~1 w 1.295× on walk_1m, 1.025× on clone_large — instructions retired, interleaved, both sides. The parallel half is withdrawn, not deferred
7 P-003 the next bottleneck once compression is gone; unblocks §4.1's whole premise, and P-010's level-synchronous walk produces exactly the batch it wants ~1 w unmeasured, structural
8 P-004 prevents the fix for S-011 from being a catastrophe ~3 d avoids a regression, not a win
9 P-011 offline batch; no request is waiting on it ~1 w latency of gc, not of serving
10 P-005, P-006, P-013, P-014 storage-design decisions to settle before wave 9b writes against them — avoids rework
— P-012 DONE 2026-08-03 done out of order: P-015 measured it at 23.7× and it is the miss path every have and every want runs through ~1 d 128× on misses over 64 packs, 21× on hits; miss_degradation_x 23.2 → 1.01

P-016, P-017, P-018 are follow-ons, not blockers on this sequence.


blocker

P-001 — Every served object is inflated and re-deflated, discarding the client's own delta compression

Worse, the client's git ran a full delta search before uploading that pack. Those deltas are in the incoming tier. add_object can only emit Blob/Tree/Commit/Tag headers (write.rs:249-257, and S-003), so every delta the client computed is inflated away and re-sent as a whole object.

The fix is already written, in a crate gunnar already depends on. gix_pack::data::output::entry::iter_from_counts has exactly one Mode variant (READ, gix-pack-0.73.0/src/data/output/entry/iter_from_counts.rs:359-367):

pub enum Mode {
    /// Copy base objects and deltas from packs, while non-packed objects will be
    /// treated as base objects (i.e. without trying to delta compress them). ...
    PackCopyAndBaseObjects,
}

The single-variant enum reads like a limitation and is usually reported as one (including in S-003's framing). It is the opposite: it is precisely the optimisation gunnar is missing. from_pack_entry (entry/mod.rs:65-131) copies an existing pack entry's compressed bytes verbatim and re-points its OfsDelta header (:89-112) — no inflate, no deflate, delta preserved. Only RefDelta is downgraded to a base (:113), and only objects with no pack residency are deflated fresh (from_data, entry/mod.rs:138-159).

How BEFORE was obtained, which is the part worth copying

Not from a memory of an earlier ref. GUNNAR_BENCH_NO_PACK_COPY=1 is an ObjectStore wrapper in the bench that returns None from packed() and changes nothing else. That is exactly the server as it was before this landed, on the same binary, the same fixture and the same machine minutes apart. Its credential is that it reproduces the historical figures to three decimals: 18.336× on the sha1 clone, 17.723× on sha256 and 7.22× on the concurrent arm, all identical to the run recorded at e60c95d under P-015. A switch that produced different wrong numbers would have been a differently broken path rather than the old one.

It is also the LAW 2 red for every assertion below, and it is permanent.

The head-to-head, oden 2026-08-03, stock git 2.53.0, one code seam apart

gunnar.vs_git_clone: sha1, 2048 churned commits over 1024 files, 528 MiB of blob content, 36 172 objects. Both columns inside the pinned container, interleaved, median of three.

BEFORE (NO_PACK_COPY=1) AFTER
pack on the wire 108.70 MB 5.93 MB 18.3× fewer bytes
pack_ratio_x against git 18.336 1.000 gunnar ships what git ships
server CPU 4.923 s 0.378 s 13.0× less
cpu_ratio_x against git 218.995 16.203
wall clock, end to end 5118.01 ms 1059.65 ms 4.8×
wall_ratio_x against git 8.358 1.323
objects copied / recompressed 0 / 36 172 36 172 / 0 the applied output

sha256 moves the same way: 115.06 → 6.49 MB (17.723 → 1.000), CPU 5.025 → 0.377 s, cpu_ratio_x 169.3 → 12.236, wall_ratio_x 6.316 → 0.854. On that arm, in that run, gunnar finished a clone faster end to end than git upload-pack. Concurrent (16 clients): pack_ratio_x 7.22 → 1.000, cpu_ratio_x 26.239 → 1.695, and rss_total_ratio_x 0.169 for one gunnar process against sixteen git ones, unchanged by this work and still 5.9× better.

gunnar.vs_git_fetch_divergent is the one arm where the copy is not total, and its number is the interesting one: 2841 copied against 71 recompressed, 97.6%. The 71 are what gix-pack cannot copy: RefDelta entries, which it downgrades to bases (entry/mod.rs:113), and deltas whose base did not come along. Wire bytes fell 1.79 MB → 0.47 MB and cpu_ratio_x 5.560 → 2.969.

The other half, isolated

gunnar.clone_large serves a 1 GiB fixture of random blobs, which delta to nothing. There is no delta reuse available on it at all, so it measures only "do not inflate and re-deflate what is already deflated":

BEFORE AFTER
clone_ms 3229.01 / 3197.72 322.59 / 346.19
pack_mbs 19.99 / 20.19 200.11 / 186.47
bytes_out 67 690 746 67 690 746, identical

9.9×, at byte-for-byte identical output. That is the number the CPU-budget table was reaching for, and the split matters: on a corpus with history the two effects compound (18.3× bytes and 13.0× CPU); on a corpus without it only the second survives. Anyone quoting a single multiplier for P-001 should say which corpus it came from.

What it cost, and what it did not

client pack bytes clone ms copied / recompressed
with ofs-delta 6 216 589 376 36 172 / 0
without, at 735728c 113 985 387 4843 0 / 36 172
without, after the fix 6 807 682 381 36 172 / 0

18.336× → 1.095× on bytes, 12.9× → 1.08× on wall clock. The fix is that OBJ_REF_DELTA needs no capability at all — it is the older of the two forms — so a copied delta can name its base by object id instead of by distance backwards. The delta bytes are byte-for-byte the same; only the header changes. WriteOptions::ofs_delta now selects which name is used, and the capability no longer decides whether copying happens.

The residual 9.5% is arithmetic, not slack: one 20-byte base id per delta entry replacing a 1–3 byte varint, 591 093 bytes over ~33 000 deltas. A sha256 repository pays 32 bytes and would sit higher.

No gix change was needed, which is worth recording because it was the expected answer. output::entry::Kind already distinguishes DeltaRef { object_index } from DeltaOid { id }; only Entry::to_entry_header maps the former to OfsDelta unconditionally, and gunnar already knows the id of the entry at that index because it has just written it. The gix gap is a missing option, not a missing capability, and it is 15 lines to route around.

Verified against stock git, not against gunnar's own reader. crates/gunnar-pack/tests/ofs_delta_off.rs builds a churned repository with real git, serves it both ways, and holds the result to git index-pack --strict and git verify-pack -v. gix's own comment calls an OBJ_REF_DELTA whose base lies inside the same pack "a discontinued practice", so acceptance by a third party was the whole safety argument. Three things the test does that the obvious version would not:

Seen RED by making write_pack_copy ignore the flag: the form assertion fails. The companion test (both forms decode to the same objects through stock git cat-file) stays green under that edit, which is stated in its own doc rather than left to be discovered — it catches a different failure.

The fix was not the entry's fix, and the difference is P-020

The Fix step above prescribes finishing the pipeline with output::bytes::FromEntriesIter. Doing so would silently have reverted P-008 (that type builds its own gix_hash::io::Write, which hardcodes sha1_checked), and reverted it in precisely the shape where P-008 measured 4.02× rather than 1.06×, because pack-copy is what removes the deflate that used to dominate the write. Nothing would have gone red; a pack written either way is byte-identical. crates/gunnar-pack/src/copy.rs therefore takes gix's entries and keeps gunnar's emitter, and xtask/tests/pack_pipeline.rs fails the build if either half drifts. That is P-020, in .nornir/transport-encoding-design.md.

What is NOT done

P-002 — gix-pack's parallel feature is never declared — the appliance build silently uses the serial code path

MEASURED by feature resolution, 2026-08-03:

So in the appliance build — gunnar-store's fs feature off, gix porcelain not linked, which is exactly the configuration §2's dependency budget exists to produce and which S-007 is about — every gix-pack parallel call site degrades to the serial one. It compiles clean and says nothing at runtime. The pack indexer on the receive path (crates/gunnar-pack/src/read.rs:27-28, 52-56, which passes an IndexOptions::thread_limit and is today the only parallelism in the tree per S-011) is single-threaded in production and multi-threaded in every test that links the porcelain.

P-003 — ObjectStore has no batch seam, so the stree batch lookup §4.1 is built around can never be reached

§4.1 chose stree over the fst trie on one decisive argument, quoted from the spec: "The batch path is what actually decides it. Serving a pack means hundreds to thousands of object lookups at once, and stree has lookup_batch_pipeline::<P> / batch_stream::<P> — software-pipelined and prefetched — for precisely that shape. fst has no batch path at all. Git's hot path is a batch path."

The trait that the cold tier must implement has no way to express a batch. The argument that selected the index cannot be acted on through the seam the index sits behind. Whichever agent writes ZnippyObjects in wave 9b will implement contains(&self, id) and the batch path will be dead on arrival. Being an optimisation that silently never activates, it will look exactly like working code.

READ, on what is being given up: STree64Mmap::lookup_batch_pipeline::<P> (znippy-zoomies/src/stree.rs:408) routes P queries in flight with _mm_prefetch per level, sorts by leaf block, issues one madvise(MADV_WILLNEED) over the touched byte range (:378-385), scans leaves sequentially, then scatters back by original index. Its doc claims 4–8× on cold pages (:359-362). Scalar find_exact gets none of it. Note also that the batch methods are single-threaded: the parallelism they exploit is memory-level, not thread-level, so this composes with P-004 rather than competing with it.

fn contains_batch(&self, ids: &[ObjectId], out: &mut [bool]) { /* default: loop */ }
fn headers_batch(&self, ids: &[ObjectId]) -> Result<Vec<Option<Header>>> { /* default: loop */ }

Then convert the three call sites that are genuinely batch-shaped: missing_objects (write.rs:227-232, the want pre-flight, a natural batch), the have scan in negotiate (negotiate.rs:53-58), and tree_closure's per-level child set (graph.rs:257-265, which already collects blobs: Vec<ObjectId> before checking them, so the batch is already materialised there and then consumed scalar-wise). Do not add a read_batch that returns owned Vec<u8>s; that reintroduces the allocation storm. P-001's read_pack_entry is the right shape for bulk reads. Two stree landmines the spec records and an implementor will hit: find_exact returns a member of a duplicate-key run and its leaf scan walks only ≤ B+1 forward, so expand in both directions; and id_at uses i64::from_le_bytes (znippy-zoomies/src/stree.rs:568-571), so the record's leading 8 bytes must be the derived key stored little-endian. You cannot point stree at a raw big-endian oid prefix at stride 20.

P-004 — gatling has no shared pool; per-request fan-out at forge concurrency spawns tens of thousands of threads

READ: gatling has no pool object and no global runtime. Module doc (gatling_forkjoin.rs:19-21): "Pure std::thread::scope; no global pool, no rayon…". Every call spawns n_workers fresh OS threads and joins them before returning (:87, :162, :239, :343, :455, :547, :613). n_workers = 0 means one per core.

At the load target that is a disaster in three independent ways:

  1. Thread explosion. 1000 concurrent clones × 64 workers = 64,000 OS threads. With S-008's missing connection limit there is nothing capping the multiplier.
  2. Spawn/join churn. ~64 clone() syscalls per fan-out, per request.
  3. Measured negative return. Measurement 3: the box plateaus at 32 threads and is slower at 64. Oversubscribing 2000× is far past that cliff.

And a fourth, from S-009: gatling blocks. Calling it from an async fn stalls a tokio worker for the whole fan-out. The house bridge is tokio::task::spawn_blocking(move || gatling_…), as nornir does at nornir/src/server_grpc.rs:149, 175, 951. But spawn_blocking's default pool is 512 threads, which S-008 already identifies as gunnar's accidental concurrency cap.

MEASURED on oden, 2026-08-05, gunnar.clone_concurrent_64, peak_threads out of /proc/self/status at concurrency = 1, at b9900c2 — the tree run 7079f265-85fb-4478-9836-9de801da9cb1 was taken on — with nothing changed between the rows but the thread_limit literal in gunnar_pack::copy:

thread_limit peak_threads
Some(1) 6, 6, 6
Some(4) 9, 9

+3 for +3 consumers, feeder and 3-thread process baseline unchanged.

This was not the trap this entry names, and the distinction is the point: the cost was a constant 2 per request, independent of core count, and the work stayed serial — the same arm measured telem_proc_cores_max = 1.49 of 32 while serving 64 concurrent clones. "About one core per transfer" held even where "one thread per transfer" did not.

5be4644 then deleted the two threads by vendoring Stepwise::Inline, so at thread_limit == Some(1) the iterator consumes on the calling thread and spawns nothing. RE-MEASURED at f2bfd3b, same binary and fixture:

concurrency 1 4 8 16 64
peak_threads 4 7 11 19 67
threads_per_clone 1.0 1.0 1.0 1.0 1.0

Exactly N + 3, no scatter. At S-008's ceiling of 256 concurrent transfers the serving tier therefore holds ~259 OS threads, not the ~768 it held that morning — worth having as a number either way, because nothing before this had one.

The bench arm asserted peak_threads <= 2 × concurrency + 8, a ceiling below the then-structural maximum 3N + 3 for every N ≥ 5, so whether it passed was decided by how much the clones happened to overlap — by box load. It read 100–124 at N=64 on an idle box and 154 in run 7079f265-85fb-4478-9836-9de801da9cb1, and went red at N=8 and N=16 on the identical binary. The ceiling is now structural (1×N + 3 + 5) and the slope is emitted as threads_per_clone, so losing Stepwise::Inline would put N=64 at 195 against a ceiling of 72.

P-005 — The znippy cold tier forecloses pack-copy and re-compresses every git object at OpenZL level 19

A git object in a packfile is already deflated, and often a delta. Putting it into znippy means: inflate it, hand it to OpenZL, store the OpenZL frame. Serving it later means: OpenZL-decode, then zlib-deflate for the wire. Every cold-tier object pays a full recompression round trip on every fetch, forever, and can never be pack-copied. git's own cold tier is a .pack, which preserves both the zlib bytes and the delta chain, and that is exactly why git upload-pack can serve most objects with a memcpy.

Compounding it: znippy's write path compresses at level 19 on the real pipeline (common_config.rs:104, let compression_level = 19;; the CLI append default is 3 and the compress verb has no flag at all), and git objects hit the already-compressed detection gap described in P-016, so they are all fed to the codec. MEASURED in znippy's own suite (.nornir/README-full.md:107, oden, 2026-07-22): archive_random_500mb → 103.62 MB/s compress, compressed_chunks = 0, ratio_x = 1. 103 MB/s spent to achieve nothing. Already-deflated git objects are that workload.

But (b) collapses the znippy question rather than settling it, and that part is Rickard's. If the cold tier stores pack entries — the type/size varint, the delta base reference, the zlib stream — then it is a packfile, and §4 explicitly rejects "packs-in-znippy" as strictly worse than either alone. §4 and this entry's fix cannot both be satisfied. Two further entries push the same way: P-006 (znippy's OpenZL codec cmake-builds C/C++ and fetches from GitHub during cargo build, against §2's appliance rule) and P-014 (znippy has no writer lock of any kind, so gunnar would have to supply the one-writer guarantee znippy will not).

So repack() was built over a consolidated packfile, git's own cold tier, which is representation (b) by construction. The choice is measured rather than asserted: 11 of 12 objects pack-copied on the sha256 fixture, the twelfth being a loose object with no pack residency to copy from. RepackOutcome carries objects_copied_from_pack and objects_recompressed for exactly the reason this entry's Verify demands. The §4.4/D14 swap protocol is written against the tier as a seam and does not change when the tier does. See crates/gunnar-store/src/repack.rs and S-006's closing note.

What is left: whether znippy earns a place under this at all, given that (b) makes it a wrapper around the format it was meant to replace, and given P-006's build-time cost. This entry stays TODO until that is answered.

P-006 — znippy requires a C toolchain, cmake and network at build time — §4's "no C toolchain" claim is false

So a znippy build needs cmake, a C and C++ toolchain, network access during cargo build, and links libstdc++. The pure-Rust decoders in the workspace (lgz/ljar/lbzip2/ruzstd) are behind the host-decompressors feature for parsing package files and are not the archive codec.

This compounds S-045 ("russh forces two C crates — the 'no C toolchain' rule is not achievable"). S-045 establishes the rule is already broken; this establishes that the storage decision breaks it much harder: a network fetch and a cmake invocation inside the build are a different class of problem from two vendored C crates, especially for a reproducible appliance image.


scale

P-007 — flate2 resolves to miniz_oxide while zlib-rs sits linked and idle — 1.7–2.1× for one line

So gunnar links two independent zlib implementations, and its own pack writer — the server's hot path — uses the slower one, while the faster one is already compiled in and used by gix for everything gix touches.

MEASURED 2026-08-03 (Measurement 2 above): deflate L6 41.0/51.5 → 85.8 MiB/s (1.7–2.1×) at a byte-identical compression ratio of 3.28×; inflate 308.4 → 554.6 MiB/s (1.8×).

Two stale beliefs to retire while here, both READ:

RE-MEASURED 2026-08-03 on oden (Threadripper PRO 3975WX, 32 threads), through gunnar's real PackWriter rather than a raw flate2 loop, best-of-7 after a warmup, one core (taskset), same frozen corpus both sides:

corpus miniz_oxide zlib-rs speedup ratio before → after
150 files, 1.70 MiB 47.4 MiB/s 80.6 MiB/s 1.70× 3.2730 → 3.2745 (+0.05%)
4293 files, 72.63 MiB 50.3 MiB/s 97.8 MiB/s 1.94× 5.0984 → 5.0534 (−0.88%)

Both land inside the predicted 1.7–2.1×. The ratio_x guard the entry asked for is crates/gunnar-pack/tests/deflate_backend.rs, on a deterministic in-repo fixture where the two backends differ by 26 bytes out of 172 KiB (0.015%).

Two corrections to what this entry claimed.

  1. "a byte-identical compression ratio" is too strong. The deflate streams are not byte-identical at all, which is how backend_is_not_miniz_oxide can detect the backend, and the ratio moves by up to ~0.9% depending on corpus. It is a free win in every practical sense and it is not a no-op on the bytes. Anything that assumed pack files keep their old names across this change would be wrong; nothing does, because a pack checksum is over whatever bytes were written.
  2. "assert miniz_oxide is not in the appliance graph" cannot be satisfied and the entry should not have promised it: russh 0.61 depends on flate2 with default features, so rust_backend → miniz_oxide is unified back into Cargo.lock no matter what gunnar declares. The crate is compiled and unused; its backend module is #[cfg(not(feature = "any_zlib"))]. The guard therefore asserts the property that actually matters, the bytes are not miniz's, instead of the property that is unreachable.

L1 is confirmed as a real trade, not a free one: 218.4 MiB/s (2.7× over L6) for 10.8% larger output on the small corpus and 11.7% on the large one. compression_level: 6 is left alone, exactly as the entry advised.

Seen RED: reverting the flate2 line makes backend_is_not_miniz_oxide fail with both sides equal.

P-008 — The pack trailer pays SHA-1 collision detection it cannot benefit from — 4.9× on the hash

MEASURED 2026-08-03: 353.5 MiB/s vs 1778.9 MiB/s, 4.9×. And, counterintuitively, gix's SHA-256 runs at 1601–1841 MiB/s, so a sha1 repository hashes ~4.7× slower than a sha256 one on identical hardware.

The important distinction, and the reason this is not simply "turn it off":

Where detection is load-bearing and where it is not, decided explicitly.

MEASURED 2026-08-03 on oden, through the real PackWriter, best-of-35 interleaved runs of the before/after binaries under identical load (the box was busy with other agents; best was stable to ~1% while median and worst were not, so best is what is quoted). Corpus: 150 files, 1.70 MiB.

pack write sha1dc trailer plain SHA-1 trailer speedup
L6 (the default today) 85.4 MiB/s 90.8 MiB/s 1.06×
L1 229.5 MiB/s 293.8 MiB/s 1.28×
L0, no deflate: the post-P-001 shape 358.5 MiB/s 1442.3 MiB/s 4.02×
SHA-256 control, L0 1384.8 MiB/s 1389.4 MiB/s 1.00×, unchanged as intended

The SHA-256 row is the control that makes the rest believable: the SHA-256 arm still calls gix, and it did not move. Confirmed on the 72.63 MiB corpus too (L6 1.04×, L0 358.3 → 1458.0 = 4.07×).

The entry's sequencing argument is confirmed by measurement, and its number was slightly low. Today's win is +6% on a whole pack write, not 3%, because P-007 landed first and made deflate cheaper, which raised the hash's share. At L0, which is the shape P-001's pack-copy produces, it is 4×. Also worth recording: after this change a SHA-1 repository hashes at the same speed as a SHA-256 one (1442 vs 1389 MiB/s at L0). The 4.7× sha1-versus-sha256 asymmetry Measurement 1 found is gone from the serve path. It remains on the object-id path, where it is the price of the property.

Verified: crates/gunnar-pack/tests/trailer_hash.rs pins the trailer against sha1sum / sha256sum — coreutils, not gunnar's own arithmetic — for a five-object pack, an empty pack, and both hash kinds, and asserts plain SHA-1 and gix_hash::hasher agree exactly on the same body. Seen RED: XOR-ing one bit into the finalized digest turns 4 of the 5 tests red (the SHA-256 one correctly stays green). The 17 real-git interop cases in gunnar-wire and git fsck --strict in gunnar-client/gunnar-store are all still green, which is the end-to-end statement that stock git accepts these trailers.

P-009 — gitoxide has no delta encoder at all; what that costs and what pack-copy recovers

So S-003's question has a definite answer: the window search would have to be hand-written. A real encoder means size-sorted sliding window (git's default: window 10, depth 50), per-candidate delta attempt, chain-depth accounting. Substantial work, with a large parallel search that is a good gatling_map_balanced candidate.

But do P-001 first, and then re-ask whether this is needed. Under §4's design a push arrives as a packfile the client's git already delta-compressed. Pack-copy serves those deltas straight back. The objects that would still need a fresh delta search are only those with no pack residency, plus RefDelta entries which gix-pack downgrades to bases (entry/mod.rs:113). The client already paid for the delta search; gunnar's job is to stop throwing the result away, which is a far smaller job than writing an encoder.

workload copied deflated fresh copied
full clone of a repacked repository (vs_git_clone) 36 172 0 100%
negotiated fetch over a 500/500 divergence (vs_git_fetch_divergent) 2841 71 97.6%

And the pack that comes out is 1.000× stock git's on the clone and 1.018× on the fetch. There is no room left for an encoder to work in. A window search would be a substantial piece of work — size-sorted sliding window, per-candidate attempt, chain depth accounting — competing for the 0–2.4% of entries that gix could not copy, on a path that already matches git byte for byte.

The residue is accounted for: RefDelta entries, which gix-pack downgrades to bases (entry/mod.rs:113), and deltas whose base was not in the requested set. Both are properties of the request, not of the compressor.

Where an encoder would still be needed, and it is not the serve path: a repository whose objects have never been in a pack at all: the loose tier immediately after a gunnar init and a series of small writes. repack() folds those, so the question is how fresh the cold tier is (P-011), not whether gunnar can compute a delta. Reopen this entry if a measurement ever shows a meaningful fraction of served entries recompressed; gunnar_recompressed is emitted on every head-to-head clone arm precisely so that fraction is visible rather than assumed.

Not done: crates/gunnar-delta exists (a D21-shaped encoder crate, owned separately) and nothing links it. That remains true and this entry is not an argument for deleting it, only for not putting it on the serve path on the strength of an unmeasured hunch.

AMENDED 2026-08-04 — the last paragraph is now wrong, and the conclusion above is still right. Two things link gunnar-delta today. gunnar-client computes deltas on push, which closed the 0.0%-deltified hole this entry could not see. And repack() now computes them too (DeltaPolicy, crates/gunnar-store/src/pathwalk.rs), because the "reopen if" condition was met from an angle this entry did not consider: not the fraction served fresh, but the quality of what is copied. Pack-copy is 100% faithful and that is exactly the problem — a repository seeded by a client with a poor encoder keeps those deltas for ever, and gc could not improve them because it short-circuited on a single-pack repository (repack.rs:647, fixed). The serve path is unchanged and must stay unchanged: no encoder runs per request, cargo tree still proves gunnar-wire, gunnar-ssh, gunnar-http and gunnar-store resolve without one, and this entry's WONTFIX for the serve path stands.

P-010 — The closure walk is one serial thread and is the bottleneck the moment P-001 lands

Once P-001 removes compression from the serve path, the walk is the clone: the CPU budget's last column is 0.22 ms/MiB, and a million-object graph traversal with a million scalar store lookups is nowhere near that. The walk becomes the bottleneck by construction, and it has never been measured.

The shapes differ and want different primitives:


What was actually measured, 2026-08-03 — and what the entry got wrong

The instrument first, because it is why these numbers exist at all. oden was not idle for any of this: /proc/loadavg read 9.4 to 110.5 on 32 cores throughout, with 17 concurrent cargo build processes and three other agents' suites running. On that box gunnar.walk_1m's own objects_per_sec moved 2.36× across five repetitions of one unchanged binary (190 996 → 450 019), and clone_large's clone_ms moved 3.79× across eight runs. Wall clock cannot resolve anything here.

Instructions retired can. Under perf stat, the same five repetitions came to 13.194, 13.197, 13.201, 13.193 and 13.192 G instructions — a 0.29% spread against wall clock's 236%. Every before/after ratio below is instructions retired, from binaries run interleaved (B, A, B, A, B, A) so that any drift in the box's load falls on both sides. Process CPU seconds are quoted as a second, independent witness and agree to within 1.4%.

The profile: what the walk actually spends itself on

perf record -F 999 over gunnar.walk_1m (333 334 commits × (blob + tree + commit) ≈ 1.0 M objects, MemObjectStore). Shares are of the whole arm, fixture build included, which is stated because it is the denominator:

before after what it is
__memcmp_avx2_movbe 14.10% 12.27% hashbrown's final key compare on an ObjectId
allocator (_int_malloc/_int_free*/realloc/cfree) ~14.2% ~14.0% one malloc per commit, two per tree, plus the fixture's own
gix_object::commit::decode::commit 8.05% 0 the full commit decoder
CommitRefIter::next_inner_ 0 5.16% what replaced it
SipHash on oids (hash_one, DefaultHasher::write) ~5.7% ~6.5% hashing a value that is already a hash
gix_actor + gix_date + the whitespace split ~3.8% (two signatures) each symbol now below the 0.5% cut author/committer signatures and dates the walk never reads. Halved, not removed: CommitRefIter announces "no more parents" by decoding the author, so one signature and one date parse per commit remain and are simply too small to list

The single biggest finding is not in that table: every commit in a clone was being read and decoded twice. commit_closure decoded each commit for its parents and threw away the tree field it had just parsed; select_objects steps 5 and 6, and verify_connectivity step 3, then asked the store for that same commit again purely to get the tree back. On the fs backend the second read is a pack-index probe, a zlib inflate and a delta chain — the odb handles are built refresh_never() with no object cache configured anywhere in gunnar-store, so nothing memoised it.

This is measured, not inferred, by an independent counter — the Counting store's read tally, which knows nothing about the profiler:

arm store touches before after difference commits in the closure
gunnar.walk_1m (mem, connectivity) 1 000 639 667 305 −333 334 333 334
gunnar.clone_large (fs, upload-pack) 8 196 6 148 −2 048 2 048

The difference is exactly one read per commit, on both backends and both directions of the protocol.

What landed

Three changes, all in crates/gunnar-wire/src/graph.rs, all of which reduce total work rather than rearrange it — which is what §"The parallelism answer" ranks above fan-out, and which is the only kind of change P-004's "the serving tier stays serial" decision permits at all:

  1. commit_closure returns the trees it already read (CommitClosure { ids, trees }). Kills the second decode. Costs one commit-sized Vec<ObjectId>: +10.63 MB peak RSS measured on the 333 334-commit fixture against 10.17 MB predicted from 32 bytes × commits, which is the whole of it.
  2. commit_links replaces CommitRef::from_bytes on the walk path. CommitRefIter decodes only as far as it is driven and git's grammar puts tree and every parent first, so stopping at the first non-parent token leaves the committer, the encoding, the extra headers and the message unparsed. Graph::commit keeps the full decode for negotiation, which is the only caller that wants committed_at and asks about a handful of commits rather than all of them.
  3. Buffers hoisted out of the per-object loops — commit_links takes the caller's buf and parents; tree_closure's per-tree children and blobs vectors are cleared rather than reallocated.

The numbers

Six interleaved repetitions per side, perf stat, oden 2026-08-03, loadavg 54–79:

arm before after ratio
walk_1m instructions retired 13.1861 G (spread 0.29%) 10.1805 G (spread 0.23%) 1.295×, 3.006 G removed
walk_1m process CPU seconds 2.765 s 2.165 s 1.277×
walk_1m peak RSS 321.7 MB 332.3 MB +3.30%, the trees vector
walk_1m commit_reads_per_commit 2.000 1.000 —
clone_large instructions retired 3.6135 G (spread 0.06%) 3.5262 G (spread 0.43%) 1.025×
clone_large bytes_out 67 690 746 67 690 746 identical
clone_large objects_recompressed / blob_reads 0 / 0 0 / 0 P-001's guards intact

Both figures are real and the gap between them is the result. 1.295× and 1.025× are the same change measured on two corpora, and the difference is entirely what fraction of the served bytes hang off a commit. walk_1m is a third commits by object count and serves 127 MB; clone_large is 2048 commits inside 6144 objects and serves 1 GiB of blob content, so its walk is a rounding error next to its pack copy and a 1.295× walk buys 2.4% of the arm. Anybody quoting one multiplier for P-010 has to name the corpus, the same way P-001's 9.9×/18.3× split does. A commit-heavy repository — a kernel-shaped history, ~13% commits by object count — sits between them.

What the arm's own guard turned out to be

walk_1m asserted store_touches >= objects. It passed only because of the bug. 333 334 of the 1 000 639 touches were the redundant reads; removing them dropped the arm to 667 305 and made it go RED against its own floor. The floor had quietly encoded the inefficiency as the invariant — the only way to satisfy it was to keep doing the extra work — and blobs, which are checked with contains and not read at all, never contributed to it in the first place. It is replaced by two bounds:

commit_reads_per_commit is now a published metric, so the regression has a number rather than a code review.

What was NOT done, and why the entry's own Fix is now argued against

P-011 — repack(), gc and index building are the true gatling batch jobs, and none exist

All four are offline, one-at-a-time, whole-repository batch jobs: no request is waiting, the input set is known up front, and the box can be given entirely to them. That is gatling_map_balanced's exact case, and unlike P-010/P-004 there is no admission problem.

job fan-out weight (size_of) notes
repack() gatling_map_balanced over objects object size the classic case. Under P-005 this becomes a pack-entry copy, not a recompress, much cheaper than it looks today.
__gunnar_oid__ (stree) STree64Mmap::new_with_stride is already parallel: it calls gatling_scanlines internally (stree.rs:260-271) — do not wrap it again; that is the nesting footgun. Note the tree is not serializable and is rebuilt in RAM at every open (stree.rs, no to_bytes/from_bytes); at 100 M keys that open cost is real and unmeasured.
__gunnar_graph__ (Arrow) gatling_map_balanced over commits, parse each commit size independent per commit; trivially parallel
__gunnar_reach__ (bitmaps) per selected commit subtree size §4.2 already says no speedup is claimed until measured. Hold that line.
gc / dead-blob reclaim gatling_map_balanced — note znippy has no GC for blobs orphaned by a replacing append (AppendReport::rows_replaced); gunnar must do it

Measured, oden 2026-08-03, 1 000 000 objects over 64 packs, sha256, load ~175:

serial (thread_limit: Some(1)) all cores
objects_per_sec 247 052 324 941 parallel_speedup_x 1.315
peak threads in the process 33 33 —
copied / recompressed — 1 000 000 / 0

Three findings, in descending order of how much they change the entry.

  1. The batch tier is already on P-001's path and always was. A repack of a packed repository copies every entry: 1 000 000 copied, 0 recompressed. The PackCopyAndBaseObjects generator is what repack() drives, so P-005's "pack-copy works from the cold tier" and this entry's "under P-005 this becomes a pack-entry copy, not a recompress" are both true today. The old arm's 87 054 objects/s was 3.7× pessimistic and of the wrong code.
  2. It already fans out, and gatling is not needed for it. This entry's table prescribes gatling_map_balanced over objects for repack(). It is already parallel, through gix-features' own std-thread pool, at 1.30–1.55× on 32 cores across three runs. Replacing that with gatling would be twinning a scheduler (LAW 5) to chase a fan-out that is present; the reason the speedup is 1.3× rather than 20× is P-018's serial tail plus the count/verify/index passes around it, and gatling does not touch any of those. The gatling_map_balanced recommendation for repack() is withdrawn. It still stands for the three index-building jobs, none of which exist.
  3. RepackOptions::thread_limit does not bound the repack. It reaches count::objects and iter_from_counts and nothing else; FsStore::write_multi_pack_index runs inside repack() and gix_pack::multi_index::write_from_index_paths takes no thread limit at all (multi_index::write::Options carries only object_hash). Hence 33 peak threads either way. An operator setting thread_limit(1) to keep a repack off the serving cores gets a slower repack, not a narrower one. Recorded on the field's own doc comment; fixing it means a knob upstream or splitting the index write into a step the caller schedules, and neither was worth guessing at here.

NOT measured, and both are gaps this entry asked for:

P-012 — Object lookup is O(packs) on a miss, and every thread carries its own unshared cache

Note gunnar's server never opens gix::Repository (it bridges through crates/gunnar-store/src/gix_adapter.rs), so none of gix's cache defaults apply. GixObjects::try_find (gix_adapter.rs:69-75) is a cache-free forward to ObjectStore::read. Whatever caching exists must be gunnar's own, and today there is none.

The diagnosis, which is not what this entry predicted

This entry said a miss is slow because it must be disproved against every pack index, so cost is linear in pack count. That is true and it is the smallest of the three terms. Measured before touching anything, on oden, over the bench suite's own fixtures, by a probe that reproduces each candidate cause in isolation:

miss path 1 pack 64 packs
as shipped: a fresh gix-odb handle per call, default refresh mode 78 603/s 3 270/s
one reused handle, default refresh mode 81 875/s 3 261/s
one reused handle, RefreshMode::Never 866 518/s 77 501/s

A miss ran 11× faster at ONE pack, where there is no O(M) term at all to explain it. gix-odb's default RefreshMode::AfterAllIndicesLoaded (gix-odb-0.83.0/src/store_impls/dynamic/mod.rs:49-58) means that when a lookup falls through every loaded index, Handle::contains (find.rs:320-345) calls load_one_index, which calls consolidate_with_disk_state (load_index.rs:106-112, 201): a full re-scan of objects/ and objects/pack, behind the store's global write lock, on every single miss. Not a pack probe: a directory scan. Measured directly, a bare statx of the pack directory is 640 ns and a bare read_dir is 2.4 µs at one pack and 30 µs at 128 entries, and the full consolidation is ~10 µs at one pack and ~296 µs at 64.

The second term was gunnar's own and this entry did not name it: FsObjects built a fresh handle for every call (fs.rs:300-302, before this change). Store::to_handle_arc() runs collect_snapshot(), which clones one Arc per loaded index into a new Vec, so handle construction is itself O(packs), and unlike the refresh it is paid on hits as well. That is the whole of the hit-path degradation: 1 073 582 hits/s over one pack against 100 323/s over 64, with no miss anywhere in sight.

Only the third term is the O(M) probe this entry is named for, and it is worth 7.2× across 64 packs once the other two are gone.

What was chosen, and why not the alternatives

  1. Pool the gix-odb handles (crates/gunnar-store/src/odb.rs), bounded at 64. The handle is Send and not Sync — its snapshot and inflate state are RefCells — so a lease is the shape that fits. This also answers this entry's third original point: memory is bounded by the pool rather than by concurrency.
  2. RefreshMode::Never on every pooled handle, which is git's own answer: has_object() passes OBJECT_INFO_QUICK by default and re-scans only when a caller opts in with HAS_OBJECT_RECHECK_PACKED, precisely because misses are the common case on a server. git then makes the writer call reprepare_packed_git(). gunnar cannot: the code that drops a pushed pack into objects/pack is gunnar-cli's PackIngest, and gunnar-wire's only contract is "after ingest returns, the objects must be readable from the ObjectStore". So the re-scan is put back where it is needed by a one-statx watch on objects/pack, trusted only when the mtime it memoised was already in the past when it was taken, because a filesystem timestamp comes from a coarse clock and a change inside the same tick as the scan is invisible. That is git's own "racily clean" rule applied to a directory. Its cost is that for one second after a pack lands a miss pays the full consolidation, which is what it paid always before, so the new path is never slower than the old one. That price is now a published metric, lookups_per_sec_miss_after_write, rather than a claim in a comment.
  3. A multi-pack index (crates/gunnar-store/src/midx.rs) for the O(M) term.

Not an stree. §4.1/§4.2 intend one and znippy-plugin-git has one, and LAW 5 says reuse rather than twin. But the measurement that justified stree also recorded that its serial path is a wash against a binary search (0.99× at a million keys) and that only the batch path wins, 1.5–2.6×. A want that misses is a single serial lookup, and P-003's batch seam does not exist.

Not a Bloom or cuckoo filter. After (1) and (2) a miss against one index over a million objects costs 2.3 µs, of which one statx and one loose-object stat are syscalls no filter can remove. A filter could delete the binary search and nothing else, and would need to be built, persisted, invalidated on every push and kept in agreement with the pack set: a second index with its own staleness protocol, which is the twin LAW 5 forbids.

The reuse that pays is git's own, and it is already in the tree: gix-pack writes a multi-pack index (multi_index::write_from_index_paths) and gix-odb reads one (use_multi_pack_index defaults to true; load_index.rs:495-533 collapses every .idx the index names into a single snapshot entry). Zero new formats, zero new lookup code, one file.

The trap, measured before it was believed

A multi-pack index on its own makes the miss path worse. Over 64 packs, misses/s:

no multi-pack index with one
as this crate was before 3 270 2 357
pooled handles, no re-scan on miss 60 337 415 392

With the default refresh mode still on, adding the index costs a further 28%: the per-miss consolidation has one more file to stat and validate. It only pays once the miss has stopped scanning the directory. Shipping the obvious fix alone would have moved the benchmark the wrong way.

BEFORE and AFTER — oden, 2026-08-03, one million distinct objects

Both sides measured in one process, interleaved, best of seven, over two byte-identical copies of the same fixture. BEFORE is not a memory of an earlier run: it is the exact code FsObjects used to run, store.to_handle_arc().exists(id) with the default refresh mode, against a copy that never had a multi-pack index written into it. Best-of rather than mean because oden was at load 45–47 on 32 cores throughout, with other agents building; the best run is the least interfered with, and interleaving means neither side gets the quiet minute. Reproduced twice within 1%.

BEFORE hits/s AFTER hits/s BEFORE misses/s AFTER misses/s
1 pack 1 073 582 2 064 523 (1.9×) 78 183 436 196 (5.6×)
64 packs 100 323 2 131 051 (21.2×) 3 376 432 113 (128×)

miss_degradation_x 23.2 → 1.01, and hit_degradation 10.7 → 0.97. The AFTER numbers are pack-count-independent to within 1%, which is the actual claim: the degradation is not reduced, it is gone. Nothing was traded: the hit path improved at both pack counts, which is the regression this could most easily have hidden.

The bench arms, and how the red survived the fix

gunnar.lookup_1m_64pack's red condition is miss_degradation_x > 1, which exists to catch a probe set that produces no real misses. A fix that makes 64 packs cost what one pack costs deletes that guard if the arm only measures the shipped configuration. So the arm now measures the same repository both ways, back to back:

Full-size row, oden 2026-08-03, single_pack_drift_x 1.052:

arm measured
gunnar.lookup_1m lookups_per_sec_hit 1 384 680 · lookups_per_sec_miss 370 068 · 1 M objects, 1 pack
gunnar.lookup_1m_64pack lookups_per_sec_miss 415 392 · lookups_per_sec_hit 1 656 633 · miss_degradation_x 6.921 · miss_degradation_x_midx 1.005 · multi_pack_index_gain_x 6.885 · lookups_per_sec_miss_no_midx 60 337 · lookups_per_sec_hit_no_midx 130 392 · lookups_per_sec_miss_after_write 9 929

⚠ These absolutes are not comparable with the ones published earlier today, and the reason is a second hollow fixture; see the correction under P-015. In short: the corpus these arms ran on contained 16 712 distinct objects, not 1 000 000, so lookup_1m was searching a sixteen-thousand-entry index that fits in L2. The corpus is fixed and the ratios above are all from the corrected one.

What is asserted, and what was watched failing

crates/gunnar-store/tests/multi_pack_lookup.rs holds nine tests, both hash kinds, both directions of wrongness, before and after a repack. Each red below was produced by breaking the implementation, not the test:

  1. Turning the refresh off can make a present object report absent. With refresh_if_stale returning false unconditionally — "just set refresh_never and ship it" — a_pack_written_out_of_band_is_found_without_an_explicit_refresh fails on the first contains of the second batch, and a_miss_answered_before_the_pack_landed_is_answered_again_afterwards fails with "the store went on answering false for an object whose pack had landed". Both write the pack the way a real push does: index_pack straight into objects/pack, telling the store nothing.
  2. A multi-pack index can make an absent object report present. With repack() made to leave the index alone, a_repack_invalidates_the_multi_pack_index_it_made_stale and a_repacked_repository_reopens_without_a_stale_index fail with "object b724…5a42 is not in the store": contains still answered true out of the stale index and the pack it points into had been unlinked. Stated honestly: disabling only the removal inside the swap does not fail these, because the rewrite at the tail removes the index a moment later. The removal in the swap closes the window for a concurrent reader and no test here earns it.
  3. A short index. With indices_in made to drop one .idx: the_multi_pack_index_spans_every_pack_and_every_object fails with "the index does not span all five packs", and objects_resolve_through_the_multi_pack_index_alone fails because the objects of the unnamed pack became unreadable the instant their .idx was gone, which is the direct evidence that lookups go through the multi-pack index rather than around it. Writing the file proves nothing on its own.

A data-loss bug this uncovered in repack(), and the guard that now stands there

real_git_clones_a_repacked_repository went red the moment the pool began scanning at open: "trying to write ref 'refs/heads/main' with nonexistent object". repack() builds its object set from Handle::iter(), which reaches load_all_indices(), documented to load every index the store already knows about, passing RefreshMode::Never; it never looks at the directory. Meanwhile the set of packs it deletes comes from a fresh read_dir. Before this change the store happened to be uninitialised at that moment, so the first iter() consolidated and the two agreed by luck. Initialising the index at open removed the luck: the repack carried one loose object across and then unlinked three packs holding everything else.

Two things now stand there. A refresh after the deletion snapshot and before the iteration, so the object set is by construction at least as new as the deletion set. And verify_folded_into, which reads the index of each pack about to be unlinked and refuses unless every object in it is in the new pack. That is the mirror of the existing verify_pack_holds, which checks the new pack against the request and so cannot see a request that was short. Watched red by removing the refresh: three tests fail with "…idx holds 51b2664f… and the repacked pack does not, so nothing was deleted": a refusal that deletes nothing, in place of silent data loss.

What is NOT done

P-013 — The iceberg backend cannot be multi-process — skade's RedbCatalog takes an exclusive file lock

What Iceberg genuinely buys a forge at a million users:

What breaks, and why the verdict is what it is:

  1. The catalog is single-process, by design and by file lock. skade/src/store.rs:12-17 funnels every write through a tokio::sync::Mutex<Database> over redb, and skade/.nornir/reasoning.md:32-36 states the cost outright: "single process. redb takes an exclusive file lock, so two processes can't open the same catalog… if you need multi-writer, you need a service, and this crate is the wrong tool." A second gunnar process cannot open the catalog at all; it fails to start. So the headline benefit, horizontal read scaling, is exactly what this catalog cannot provide. The designed escape is skade-katalog serve (one lock-owning process fronting REST/gRPC), which the design docs mark as NEW/bench-filed: a plan, not something to depend on today.
  2. Per-object read latency is wrong by orders of magnitude. A git object read is a sub-millisecond operation on a hot path taken thousands of times per clone. An S3 GET is tens of milliseconds. Iceberg's metadata layers add more. This is survivable only for a cold, bulk-read tier, never for the object store a graph walk hits.
  3. IcebergZnippyReader::open is eager and O(all rows): it scans every table in the namespace and builds an in-memory HashMap<String, FileEntry>, with no lazy or trie path. At a million objects per repository across millions of repositories that does not close.
  4. extract_file does not verify checksums, explicitly (znippy-iceberg/src/lib.rs:341-343). §4's property 2 ("per-chunk blake3 verification on every read") is lost on the Iceberg path. That is a silent downgrade of an advertised integrity guarantee.
  5. Write granularity is one Iceberg commit per sub-index, at seal time, with all sub-index IPC bytes held in RAM until finish(). That is a batch-publish shape, not a serving shape.

Verdict: keep iceberg off by default and do not treat it as a scaling story. Its honest role is a cold-archive and analytics tier: publish sealed, immutable per-repository archives into Iceberg for SQL over the whole forge, time travel, and cheap S3 durability, while the serving path never reads through it. Marketing it as the answer to horizontal read scaling would be wrong on the evidence: the catalog is a single process holding an exclusive file lock, which is the opposite of horizontally scalable. If gunnar ever genuinely needs multi-process, the answer is skade-katalog serve or a different catalog, and that is a project, not a feature flag.


correctness

P-014 — znippy has no writer lock of any kind; two concurrent appends silently corrupt the archive

INFERRED, high confidence, from the append algorithm: append_files computes blob_end as the lowest index_offset over the sealed archive, pwrites new blobs past it, and rewrites the entire metadata tail. Two concurrent appends both compute the same blob_end, both pwrite to overlapping offsets, and both rewrite the tail. The result is mutual corruption with no error from either writer.

gunnar's design makes this reachable. §4.3 takes one archive per repository, and a repo can be pushed to concurrently by several clients; repack() (S-006) is a whole-archive rewrite; and S-010 notes the open-repository handle cache is unbounded, so nothing currently guarantees one writer per archive. znippy will not tell gunnar it got this wrong: there is no error path, only a corrupt archive discovered later.

Two related hazards, same source: a replacing append orphans the old blob bytes with no GC (AppendReport::rows_replaced), and finish() re-sorts and rebuilds the whole metadata tail, i.e. O(total rows), not O(new), which §4.2 already knows and is the stated reason the object archive is never appended per push.

P-015 — No bench harness exists; the proposed nornir-bench suite and its red conditions

Layout. gunnar already has an xtask crate, so the harness goes at xtask/examples/nornir-bench.rs (the runner auto-detects this location and adds -p xtask). The file is one line:

fn main() -> anyhow::Result<()> { nornir::bench::api::run_main_json() }

Arms register via nornir::register_bench!(TheStruct { … }); the macro takes one expression and nothing else; id, metrics and workload all come from the Bencher trait (id() + run() -> Result<BenchResult>). Metrics are a free-form JSON map, and the suffix is the type system: _mbs/_per_sec/ops_sec are higher-is-better, _ms/_us/p50/p99 are lower-is-better, _x/_pct are neutral. Name ids gunnar.<scenario>.

⚠ Correction to a working assumption. The brief said results are "historized by git SHA". They are not. READ: bench_runs has columns run_id, repo, ts_micros, date, version, machine, cores. No git_sha, no release_id (nornir/src/warehouse/iceberg_schema.rs:127-140), and build_bench_runs_batch writes exactly those seven. SHA attribution is indirect, via release_lineage + regression_trace. Bench numbers themselves are keyed by (repo, machine, version, timestamp). Do not design gunnar's suite assuming per-SHA keying it will not get. (znippy/.nornir/benching.md:18-21 and nornir/.nornir/bencher.md:23-25 both claim otherwise; the code is the authority.)

The suite.

id workload metrics what red means
gunnar.capabilities static machine facts n_cores, sha_ni, l3_kb never red (source: static, gate-exempt). Present so every other row is interpretable.
gunnar.pack_deflate deflate a fixed object corpus deflate_mbs, ratio_x P-007's guard. ratio_x must not move; if it does, the backend swap traded quality for speed. The correctness half of this now exists as crates/gunnar-pack/tests/deflate_backend.rs (2026-08-03) and pins bytes; deflate_mbs still needs this harness.
gunnar.pack_trailer_hash hash a fixed byte stream hash_mbs P-008's guard. Its correctness half is crates/gunnar-pack/tests/trailer_hash.rs (2026-08-03), checked against sha1sum/sha256sum; hash_mbs still needs this harness.
gunnar.clone_large full clone of a ~1 GB repacked fixture repo clone_ms, bytes_out, objects_copied_from_pack, objects_recompressed, blob_reads, pack_mbs the headline. The copy split is P-001's applied-output assertion: a byte count and a wall clock cannot tell a pack-copy from a re-deflate. Since 2026-08-03 it asserts recompressed == 0 against a repacked fixture and blob_reads == 0 from the store as an independent second witness; the fixture was unpacked before that and the assertion could not fail. Red: GUNNAR_BENCH_NO_PACK_COPY=1.
gunnar.clone_no_ofs_delta the churned fixture served twice, with and without the client's ofs-delta capability no_ofs_bytes_x, bytes_out_ofs, bytes_out_no_ofs, copied_no_ofs, recompressed_no_ofs P-001 for a conservative client. Red at 735728c: 18.336×. Now 1.095×, the residue being one base id per delta. The ceiling is 1.20 and derived, not picked.
gunnar.clone_cold_tier same, served entirely from the znippy cold tier clone_ms, bytes_out, objects_recompressed P-005's guard.
gunnar.clone_concurrent_64 64 simultaneous clones p50_ms, p99_ms, clones_per_sec, peak_threads P-004's guard. peak_threads is the one that catches the naive fan-out; latency alone will not.
gunnar.push_large receive + index + connectivity-check a large pack push_ms, index_mbs, objects P-002's guard, run in both feature configurations; identical numbers mean the flag did nothing.
gunnar.walk_1m closure walk over a million-object graph objects_per_sec, commit_reads_per_commit P-010. objects_per_sec alone is useless on a shared box — it moved 2.36× across five repetitions of one binary on 2026-08-03. commit_reads_per_commit is the arm's real guard: 2.000 before P-010, 1.000 after, and it is a count, not a clock.
gunnar.lookup_1m 10k random oids against a million objects lookups_per_sec_hit, lookups_per_sec_miss P-003, P-012. Hit and miss separately — a suite that only measures hits cannot see P-012.
gunnar.lookup_1m_64pack same set spread over 64 packs, probed with and without a multi-pack index lookups_per_sec_miss, miss_degradation_x, miss_degradation_x_midx, multi_pack_index_gain_x, lookups_per_sec_miss_after_write P-012's O(M) probe, and since 2026-08-03 the measurement of the fix. miss_degradation_x is taken with the index removed, so the original red — degradation ≤ 1 means no real misses — survives a fix that makes 64 packs cost what one costs. multi_pack_index_gain_x > 1 and miss_degradation_x_midx < miss_degradation_x are the fix's own applied outputs.
gunnar.repack_1m repack a million-object repository through FsStore::repack objects_per_sec, objects_per_sec_serial, parallel_speedup_x, objects_copied_from_pack, peak_threads P-011. Rewritten 2026-08-03: the previous version measured a hand-rolled whole-object loop the shipped repack does not run. seal_ms is gone because repack() exposes no seam at the seal, which is recorded as a gap rather than filled with arithmetic.
gunnar.negotiate_divergent have/want against a genuinely divergent merge history negotiate_ms, rounds, commits_visited the classic git-server failure. A three-commit fixture cannot see it (negotiate.rs:4-10 says so).

The gate. House default is a >10% regression = red, configurable per repo as [repo.gunnar.gates] max_regression_pct (some repos use 5.0). Baseline is the newest prior run on the same machine, not best-ever and not a median; first run on a machine bootstraps green. A regressed run is not persisted, so it can never become a future baseline. Direction is inferred from the metric-name suffix. Add core_saturation (peak core-busy ≥ 0.9 × cores) to repack_1m and clone_concurrent_64: a parallel bench that peaks below that is a bug, not Amdahl. Do not wire that rule against telem_cores_busy_max — measured 2026-08-03, it reads the whole host and returns 32/32 for a single-threaded arm, so the gate can never go red. The input has to be the process's own task-clock ÷ wall. See the core_saturation bullet under P-015.

The trap to avoid on day one. inventory collects arms at link time, so a bench crate that dev-deps nornir inherits nornir's entire arm set. This really happened (2026-07-22: nornir bench run skade ran 213 nornir.mimir.* arms and reached zero skade.* arms). Set NORNIR_BENCH_ONLY=gunnar. and make a zero-arm run a hard failure. An empty run is RED, never a silently green zero-arm success, exactly the disease S-026 and the fleet's silent-green-guard-disease finding are about.

Two deviations from the plan above, both deliberate:

  1. Not xtask/examples/. xtask/Cargo.toml declares "ZERO dependencies, deliberately". It is the §2 dependency guard, and a workspace member, so a nornir dev-dep there would take part in resolver-2 feature unification and be built by cargo test --workspace. bench/examples/nornir-bench.rs is the third accepted location in nornir/.nornir/bencher.md and exists for this case. Verified: cargo metadata at the repo root still resolves 12 packages and does not list gunnar-bench; the root Cargo.toml was not touched.
  2. Dev-deps nornir-bench, not nornir. nornir re-exports nornir_bench verbatim (nornir/src/lib.rs:159), so the API is identical. But linking nornir links every register_bench! in nornir/src/bench_registry.rs, which is the day-one trap this entry warns about. Linking only the leaf means the linked arm set is gunnar's arm set. NORNIR_BENCH_ONLY=gunnar. is still set from main(), because a future dependency could reintroduce the hazard silently.

The git_sha correction is CONFIRMED, not merely repeated. Read directly: nornir/src/warehouse/iceberg_schema.rs:127-141. bench_runs() builds a schema of exactly seven fields, ids 1–7: run_id, repo, ts_micros, date, version, machine, cores. No git_sha, no release_id. build_bench_runs_batch (nornir/src/warehouse/iceberg.rs:2440-2458) writes exactly those seven columns in that order and nothing else. znippy/.nornir/benching.md:18-21 and nornir/.nornir/bencher.md:23-25 are wrong; the code is the authority. gunnar's suite is designed for (repo, machine, version, timestamp) keying.

First full-size run — machine oden, 2026-08-03, 32 cores, sha_ni, sha256 repositories, NORNIR_BENCH_PREVIEW unset. All 11 arms passed.

id measured
gunnar.capabilities n_cores 32 · sha_ni 1 · avx2 1 · l3_kb 16384 (per-CCX; the host has 8 × 16 MiB)
gunnar.pack_deflate deflate_mbs 130.70 · ratio_x 2.8532 · 16384 × 16 KiB = 256 MiB in, 94 083 266 B out, level 6
gunnar.pack_trailer_hash hash_mbs 1890.05 over 2 GiB in 64 KiB chunks
gunnar.clone_large clone_ms 3245.87 · bytes_out 67 690 746 · objects_recompressed 2048 · pack_mbs 19.89 · objects_sent 6144 (1 GiB fixture: 2048 commits × 512 KiB). ⚠ unpacked fixture, pre-P-001; after the fixture was repacked and P-001 measured, the same arm reads clone_ms 322.59 · pack_mbs 200.11 · objects_copied_from_pack 6144 · objects_recompressed 0 · blob_reads 0
gunnar.clone_concurrent_64 p50_ms 987.75 · p99_ms 1043.64 · clones_per_sec 60.36 · peak_threads 67 · aggregate 135.05 MB/s. ⚠ same unpacked fixture. Repacked and on P-001's path (2026-08-03): p50_ms 129.72 · p99_ms 158.26 · peak_threads 89 for 64 clones. clones_per_sec read 48.5 and 268.6 in two runs at load 186 and was called not quotable; peak_threads is, and it is P-004's guard reaching the copy writer for the first time. Re-measured with repetitions 2026-08-03 (7 runs, one binary, oden at loadavg 37): clones_per_sec 164.1, 469.2, 530.2, 530.3, 543.5, 557.9, 599.5 — median 530.3, full range 3.65×, and 1.28× across the six that are not the one outlier. p50_ms 90.3–269.0, p99_ms 104.0–370.4, peak_threads 94–134 for 64 clones. Instructions retired over the same seven runs spread 5.4% (1.4% excluding the outlier), so the arm's work is stable and it is only its rate that is not. The honest form of this number is a distribution with the box's load beside it, not a single figure: quote the median and the range, and treat any single reading as meaningless.
gunnar.push_large push_ms 255.19 · index_mbs 121.23 · index_mbs_serial 49.57 · parallel_speedup_x 2.446 · objects 3072 · 33.9 MB pack
gunnar.walk_1m objects_per_sec 710 340.8 · walk_ms 1407.78 · 1 000 002 objects · 1 000 639 store touches
gunnar.lookup_1m lookups_per_sec_hit 2 189 546.9 · lookups_per_sec_miss 82 797.8 · 1 pack, 10 k probes — ⚠ 16 712 objects, not 1 M, see the correction below
gunnar.lookup_1m_64pack lookups_per_sec_miss 3 355.4 · lookups_per_sec_hit 529 962.5 · miss_degradation_x 23.655 · single_pack_drift_x 0.959 — ⚠ same corpus
gunnar.repack_1m objects_per_sec 87 054.0 · seal_ms 0.002 · 1 M objects, 64 packs in, 105 621 680 B out. ⚠ of a hand-rolled loop FsStore::repack does not run; the real repack, measured 2026-08-03, is objects_per_sec 324 941 · objects_copied_from_pack 1 000 000 · objects_recompressed 0 · parallel_speedup_x 1.315. See P-011.
gunnar.negotiate_divergent negotiate_ms 154 490.44 · rounds 1250 · commits_visited 18 901 251 (20 k shared base + 20 k divergence each side)

Read these numbers with the host load in hand. oden was running four other build agents; telem_cores_busy_max is emitted per arm and several arms saw 32/32 from work that was not theirs. An earlier full run of the same suite, at load average 57, gave hash_mbs 1616 (vs 1890) and pack_mbs 28.14 on a differently-generated corpus. Treat absolute values as ±30% until a quiesced run exists. The ratios inside one arm (miss_degradation_x, parallel_speedup_x) are bracketed or back-to-back and are far more trustworthy than the absolutes.

What the first run already says, beyond "it runs":

Looked at, 2026-08-03, S-047 in stinky.md: the headline was misleading. Neither the 1250 rounds nor the 18.9 M visits was the cost. 1250 is 40 000 ÷ 32, the bench's own fixed-window client; real git doubles its window and abandons negotiation after MAX_IN_VAIN, so it would take ~10 rounds here. And the single most expensive thing in the arm never touched the object store at all: a linear Vec::contains de-duplicating common, which was 81% of the wall clock while changing commits_visited by exactly zero. The real shape is that a v2 client re-sends its whole have list every round, so a round answered from scratch is O(rounds²). MEASURED in a worktree pinned at 87524d6, same corpus, same box: 197 248 → 17 905 ms and 18 901 251 → 6 401 251 commit reads, repeated at 173 757 → 16 140 ms. The remaining O(rounds²) term is the reachability walk being rebuilt per round, which S-047 names rather than claims to have fixed.

⚠ It did not work, and the arm went on reporting 2048 after P-001 landed. See the fifth hollow fixture below. What noticed P-001 was vs_git_clone, whose guard went red for the right reason; this arm stayed green on the wrong one.

Reds actually watched failing (LAW 2: no green accepted that has not been seen red). All four are reproducible from the environment, not from a code edit, except where noted:

  1. Zero-gunnar.*-arm run. NORNIR_BENCH_ONLY=gunnar.nonexistent. → exit 1, RAGNARÖK: this run would execute 0 arm(s) and NOT ONE of them is a 'gunnar.' arm. run_main_json's own empty-run bail is not sufficient on its own; it only fires when something was linked and filtered away, so main() asserts the post-filter set contains a gunnar. arm before handing over.
  2. clone_large's objects_recompressed (expectation deliberately shifted by one): arm reports passed: false and emits no result row.
  3. negotiate_divergent's commits_visited > 0, via the permanent red switch GUNNAR_BENCH_NEG_NO_SHARED=1: "commits_visited = 0 after 16 rounds — no have was ever common, so the server never walked for a cut point".
  4. lookup_1m_64pack's degradation, via GUNNAR_BENCH_PACKS=1: "misses against 1 packs run at 71589/s versus 61592/s against one pack … so no real misses are being produced (P-012)".

Two hollow guards were found in this suite's own first version, by trying to break them:

⚠ A FOURTH hollow fixture, found on 2026-08-03 while closing P-012, in the same generator and by the same mechanism. packed_fixture asked build_blobs for a million blobs and got 16 712 distinct ones: Lcg::text assembles an object out of whole lines from a 128-line pool, and a 96-byte object is two or three of them, so the corpus could only ever hold a few thousand. oids.bin held 1 000 000 entries and 16 712 unique ids. gunnar.lookup_1m and gunnar.lookup_1m_64pack were resolving against a sixteen-thousand-object index small enough to sit in L2, under a name that says a million, and gunnar.repack_1m shares the fixture. Every absolute in those three rows above is against that corpus and is not comparable with anything measured after the fix. The ratios — miss_degradation_x 23.7 among them — held up: the corrected corpus gives 23.2.

It was caught by an applied output, not by inspection: the new multi-pack index reports how many objects the file that landed actually holds, and the arm asserted that against the objects the fixture claimed. build_blobs now prefixes each blob with its own index line, packed_fixture asserts every id is distinct, and the fixture cache key carries PACKED_CORPUS_VERSION so a stale corpus is regenerated rather than reused.

⚠ A FIFTH, found 2026-08-03 while closing P-001, and it is the one this suite was specifically built to prevent. gunnar.clone_large, described above as "the headline" and as P-001's applied-output assertion, served a repository with zero packs in it. build_history writes loose objects and nothing packed them, so write_pack_copy had nothing to copy from, fell through to a fresh deflate for every object, and the arm reported objects_recompressed = 2048 (the pre-P-001 number) for the whole morning after P-001 had landed and was working. The assertion could not fail, because on a corpus with no packs a working pack-copy and a dead one produce the same output. That is the identity value, and P-001's own Verify step names it.

Two further holes in the same arm, both found by fixing the first:

Both fixtures are repacked, both cache keys carry a -p generation so a stale loose tree cannot be silently reused, and the arm now asserts the copy split from the generator and blob_reads == 0 from the object store: two witnesses produced by different crates. The red is GUNNAR_BENCH_NO_PACK_COPY=1.

A third correctness bug, in the fixture generator rather than a guard: the synthetic corpus deflated at 13.2×, then (after a fix) at 1.35×, the latter being the entropy bound, i.e. deflate's match finder doing literally nothing. ratio_x is P-007's guard, so a corpus that no match-finder change can move is a guard that cannot fire. Cause: an LCG's low bits have period 2^k, so next_u64() % 128 is a permutation cycle and no line ever repeated inside one object. Fixed (high bits, 128-line pool, measured against a zlib reference) to 2.85×, where real source trees sit.

Not built, and what that leaves unmeasured:

The host-independent replacement is the process's own task-clock ÷ wall, which is "mean cores occupied by the thing under test" and is what the gate meant. Measured, seven repetitions of gunnar.clone_concurrent_64, oden at loadavg 37 on 2026-08-03:

telem_cores_busy_max (host) task-clock ÷ wall (self)
walk_1m, one thread 32 / 32 = 1.00 0.56 cores
clone_concurrent_64, 64 threads 27–32, mean 30 → 0.94 10.99–12.11 cores, mean 11.7

So the concurrent arm really occupies about 11.7 of 32 cores, a saturation of 0.37, where the published metric says 0.94. perf stat -e task-clock gives the real number today with no code change; the sampler could give it from /proc/self/stat's utime+stime instead of /proc/stat, which is a one-field change in nornir-bench's telemetry and is the actual fix. The pinned-vCPU KVM this entry reserved would make the host reading meaningful but is not needed for the self reading, and the self reading is the one the gate wants.

Run it: cd bench && NORNIR_MACHINE=oden cargo run --release --example nornir-bench (add NORNIR_BENCH_PREVIEW=1 for the shrunk development loop, which stamps the machine oden-preview so it can never overwrite a published row). Fixtures are generated lazily and cached under $GUNNAR_BENCH_FIXTURES, default /home/rickard/scratch/gunnar-bench-fixtures: md1, never /tmp, which is RAM here.


The head-to-head — gunnar versus stock git, added 2026-08-03

Eleven arms measuring gunnar against gunnar catch regressions and answer nothing about whether the project is competitive. S-044 asked for "clone wall clock and pack size against stock git on the same fixture"; five arms now do that.

gunnar.vs_git_clone · gunnar.vs_git_clone_sha256 · gunnar.vs_git_push · gunnar.vs_git_fetch_divergent · gunnar.vs_git_clone_concurrent

How the comparison is made fair

  git clone "ext::<bench binary> %S <repo>"     <- the CLIENT is stock git on BOTH sides
        │
        └─ spawns: <bench binary> git-upload-pack <repo>          ← THE METER
                      │  pumps the pipe both ways, counting bytes
                      │  reaps the server with wait4 → CPU seconds + peak RSS
                      └─ spawns the SERVER:
                           git    :  git upload-pack <repo>
                           gunnar :  <bench binary>, gunnar-wire in-process

The meter is the same process on both sides, so its cost — one extra process, two pumping threads, one memcpy of the stream — is in both columns and cancels out of every ratio. wait4 is what makes CPU honest: Linux returns RUSAGE_BOTH, so git upload-pack's fan-out into git pack-objects is counted. A server that wins wall clock by burning four cores loses cpu_ratio_x, which is the point of measuring it.

The container, and why both sides are in it. P-015 above calls for a container for exactly these arms and for exactly one reason: to make the stock-git toolchain's presence and version non-optional, because a self-skipping interop suite that reports green is S-026. The image is docker.io/library/ubuntu:26.04 + git=1:2.53.0-1ubuntu1, the host's own distribution and the host's own git package, so the pinned git inside is the same build as /usr/bin/git outside and the glibc inside is the host's, which is what lets the bench binary run in there unchanged. Both sides run inside one podman run: containerising only the stock-git arm would have added a namespace-and-cgroup asymmetry far larger than the pinning it bought. The container is entered once per arm, outside the timed region. bench/Containerfile; build with podman build -t gunnar-bench-git:2.53.0 bench/. No KVM, per P-015.

Measured — machine oden, 2026-08-03, 32 cores, git version 2.53.0

⚠ This table is the run at e60c95d, i.e. BEFORE P-001. It is kept because it is the argument that made the case for pack-copy, and because a before is worth nothing once it has been overwritten. The after is the next section, measured the same afternoon on the same fixtures.

Fixture: 2048 commits over 1024 files in 32 directories, 16 KiB blobs, ~2% of the lines of eight files rewritten per commit. Churn, not fresh random blobs. The eleven arms above generate a new random blob per commit, which gives delta compression nothing to find and would have flattered gunnar enormously here, since delta reuse is precisely what P-001 costs. 528 MiB of blob content, 36 172 objects in a full clone, git repack -adf at default window and depth. Three interleaved rounds per side, median reported, one warm-up per side discarded.

arm metric gunnar git 2.53.0 ratio
vs_git_clone (sha1) bytes on the wire 108.70 MB 5.93 MB 18.336× worse
wall clock, end to end 4971.1 ms 461.8 ms 10.764× worse
server wall / server CPU 4925.7 ms / 4.875 s 97.5 ms / 0.022 s 219.963× worse on CPU
peak RSS 17.33 MB 12.06 MB 1.437× worse
RSS floor (1-commit repo) 6.07 MB 4.91 MB —
gunnar_drift_x (first/last gunnar round) 0.996 — —
vs_git_clone_sha256 bytes 115.06 MB 6.49 MB 17.723× worse
wall clock 5107.1 ms 527.6 ms 9.680× worse
server CPU 4.994 s 0.028 s 176.413× worse
vs_git_fetch_divergent bytes (1008 haves each) 1.79 MB 0.33 MB 5.4× worse
wall clock 177.5 ms 124.1 ms 1.430× worse
server CPU 0.143 s 0.026 s 5.560× worse
objects sent 2912 2979 gunnar cut better
vs_git_clone_concurrent ×16 bytes 193.17 MB 26.77 MB 7.22× worse
wall clock (batch) 1126.9 ms 444.2 ms 2.537× worse
server CPU 7.03 s 0.265 s 26.481× worse
p50 / p99 997.9 / 1047.7 ms 387.8 / 406.4 ms ~2.6× worse
total RSS, whole batch 19.23 MB (1 process) 135.95 MB (16 processes) 0.141×, 7.1× better
vs_git_push wall clock 384.4 ms 493.3 ms 0.779×, gunnar wins
server CPU 0.332 s 0.451 s 0.736×, gunnar wins
peak RSS 20.62 MB 54.73 MB 0.377×, gunnar wins
bytes in 1.28 MB 1.28 MB 1.00, the client built both

All five arms are from one run (NORNIR_BENCH_ONLY=gunnar.vs_git), so the columns are comparable with each other rather than stitched together from separate sittings.

The ref is part of the measurement. These are gunnar at e60c95d. An earlier run of the identical arms at 87524d6 put vs_git_fetch_divergent at cpu_ratio_x 6.647 and wall_ratio_x 2.151; e60c95d's negotiation fix (197 s → 17.9 s on negotiate_divergent) moved them to 5.560 and 1.430. The clone rows did not move at all (pack_ratio_x is 18.336 in both runs to three decimals), because negotiation is not what a clone spends its time on. Quoting a vs_git_* number without its ref is how a stale ratio outlives the change that fixed it.

What it says

  1. gunnar loses the serve path, badly, and P-001 is the whole reason. 528 MiB of content leaves gunnar as 108.70 MB (a 4.9× plain deflate) and leaves git as 5.93 MB (89×, because git ships deltas it has already computed and stored). 18.34× is the delta-reuse gap made concrete. This is the single strongest argument in this file for P-001, and it is now a measurement rather than an inference: P-001's §"31× on the compression+hash budget" was derived arithmetic; this is a clone.
  2. The hash algorithm is not the cause. sha256 moves the byte ratio from 18.336× to 17.723× and the CPU ratio from 219.963× to 176.413×. Anyone reading P-008 should note that switching hash cannot rescue this row.
  3. 219.96× on CPU is worse than 10.8× on wall clock, and that gap is the finding wall clock alone would have hidden. git served the clone in 97.5 ms of server wall for 22 ms of CPU: I/O-bound on a pack it copies, using under a quarter of a core. gunnar spent 4.875 s of CPU in 4.926 s of server wall: one core, saturated, doing deflate work P-001 argues should not happen at all.
  4. gunnar wins the receive path outright: 22% less wall clock, 26% less CPU and 38% of the memory of git receive-pack. The wire bytes are equal by construction (the client builds the pack and the client is stock git in both columns), so this row is purely server-side cost, and gunnar's index_thin_pack + connectivity walk beats git's index-pack --fix-thin + check-connected.
  5. gunnar wins total memory under concurrency by 7.1× (19.23 MB against 135.95 MB), because it is one process where git is sixteen. rss_ratio_x on that row reads 2.182 against gunnar and is the misleading number: it compares one gunnar process with one of git's sixteen, which is why rss_total_ratio_x is emitted beside it.
  6. FsStore::open_or_init takes a cross-process exclusive lock on the repository (gunnar-store/gunnar-store.lock, the Arrow log's writer lock). Run process-per-connection like git upload-pack, fifteen of sixteen gunnar servers die with "the store is locked by another writer". This is not a bug and not a bench limitation: it is why gunnar serve is one process with a thread per connection, and it means gunnar has no process-per-connection deployment shape at all. The concurrent arm therefore runs each side in its own native shape and says so on the row.
  7. gunnar negotiates slightly better than git, which was not expected: 2912 objects against 2979 for the same 500/500 divergence over a 2000-commit base, with the same 1008 have lines offered to both.

AFTER P-001 — the same five arms, oden, 2026-08-03

One run, NORNIR_BENCH_ONLY=gunnar.vs_git, at 4d69510, same container, same pinned git 2.53.0, same fixtures. oden was at load average 186 on 32 cores from other agents. So the ratios, which are interleaved gunnar/git inside each arm, are what may be quoted, and the absolute milliseconds may not.

arm metric gunnar git 2.53.0 ratio was
vs_git_clone (sha1) bytes on the wire 5.93 MB 5.93 MB 1.000× 18.336×
server CPU 0.378 s 0.023 s 16.203× 219.963×
wall clock, end to end 1059.65 ms 800.79 ms 1.323× 10.764×
peak RSS 22.57 MB 12.11 MB 1.864× 1.437×
copied / recompressed 36 172 / 0 — — 0 / 36 172
vs_git_clone_sha256 bytes 6.49 MB 6.49 MB 1.000× 17.723×
server CPU 0.377 s 0.031 s 12.236× 176.413×
wall clock 1502.56 ms 1758.65 ms 0.854×, gunnar wins 9.680×
vs_git_fetch_divergent bytes 0.47 MB 0.33 MB 1.42× 5.4×
server CPU 0.075 s 0.025 s 2.969× 5.560×
copied / recompressed 2841 / 71 — 97.6% copied 0 / 2912
vs_git_clone_concurrent ×16 bytes 26.75 MB 26.75 MB 1.000× 7.22×
server CPU 0.480 s 0.283 s 1.695× 26.481×
p50 / p99 1657 / 1808 ms 1132 / 1489 ms ~1.4× ~2.6×
total RSS, whole batch 23.30 MB (1 process) 137.48 MB (16) 0.169× 0.141×
vs_git_push wall clock 670.44 ms 615.90 ms 1.089× 0.779×
server CPU 0.340 s 0.461 s 0.737×, gunnar wins 0.736×
peak RSS 21.56 MB 54.98 MB 0.392×, gunnar wins 0.377×

What changed, and what did not.

  1. The 18.34× is gone, exactly. gunnar's clone is now byte-for-byte the same size as git's, because it is shipping the entries git repack wrote. Points 1 and 3 of What it says above are answered; point 2 (the hash is not the cause) is confirmed rather than overturned: sha256 tracks sha1 to three decimals on the byte ratio in both runs.
  2. CPU is still 16× git's, and that is now the whole story of this arm. 0.378 s against 0.023 s for identical output. git does the same job in a twentieth of the CPU because git upload-pack reuses its own reachability bitmaps and streams a contiguous region of one pack; gunnar walks the graph object by object (P-010) and reassembles entries one at a time through gix's generator (P-018). Those two entries are now the whole of the remaining serve-path gap, which is what the CPU-budget table predicted would happen.
  3. vs_git_push did not move, as expected: the receive path never touched write_pack_copy. gunnar still wins it on CPU and memory. The wall-clock column flipped from 0.779× to 1.089× and that is host load, not a regression; the CPU column, which is the load-insensitive one, is 0.737× against 0.736×.
  4. The concurrent arm's memory advantage is unchanged at ~5.9×, and its CPU ratio fell from 26.481× to 1.695×.

Every asymmetry that could not be removed

Stated here rather than left to be discovered, because a number with an unstated caveat is worse than no number:

Reds actually watched failing

Three permanent switches, all reproducible from the environment, plus four genuine failures watched while building the arms (three below, and the cross-process store lock in point 6 above, which killed fifteen of sixteen gunnar servers the first time the concurrent arm ran).

  1. Toolchain absent. GUNNAR_BENCH_H2H_IMAGE=localhost/gunnar-bench-git:no-such-tag → every head-to-head arm fails with "the pinned head-to-head image … does not exist … (a head-to-head arm that skips because its toolchain is missing is S-026)". The arms also assert git --version equals git version 2.53.0 and fail otherwise: a pack-size ratio is a statement about a specific git.
  2. GUNNAR_BENCH_H2H_SAME_SIDE=1: serve gunnar in both columns while still labelling one git. All five arms fail: "both sides put exactly 120108 bytes on the wire, which means one server was measured twice". This is the exact hollow guard lookup_1m_64pack had in its first version.
  3. GUNNAR_BENCH_H2H_SHALLOW_GIT=1, asking the git column for --depth 1. Both sides return valid, fsck-clean repositories with plausible byte counts; the arms fail with "gunnar transferred 190 objects and git transferred 22. The two sides did not answer the same request, so no ratio below is meaningful." Without that assertion, a gunnar that quietly sent a smaller object set would report a flattering pack ratio.

A hollow guard was found in this suite's own first version, by trying to break it. Switches 2 and 3 were set on the host and never reached the driver inside the container: h2h_run forwarded only the variables it knew about, so both red runs reported a cheerful green. A switch whose entire job is to make a guard fire, silently doing nothing. The whole GUNNAR_BENCH_ family is now forwarded across the boundary. Found by running the reds, not by reading the code (LAW 2).

Three more reds that were real bugs rather than switches, all seen failing:

Still not measured


smell

P-016 — znippy's incompressible list is extension-only, and git objects have no extension

} else {
    false          // no extension => compress it
}

Git objects have no extension. §4 keys them by oid hex as relative_path, which matches nothing in the table, so every git object is fed to OpenZL at level 19.

Correctness is safe. There is a content-based fallback, but it is "compress first, then discard": slot_packer.rs:244-252 compresses into scratch and, if n >= len, stores raw. So a deflated pack entry or a zlib loose object is never inflated or damaged. The cost is the problem, not the correctness. MEASURED in znippy's own suite: archive_random_500mb → 103.62 MB/s compress, compressed_chunks = 0, ratio_x = 1 (znippy/.nornir/README-full.md:107, oden, 2026-07-22): full level-19 CPU spent to discover the data was incompressible.

Worse for gunnar specifically: the append path has no extension check at all (meta_sink_append.rs:645-651 always runs the codec first), so append_files / create_archive pay the futile cost on 100% of already-compressed input regardless of name. And there is no per-entry "store raw" hint: ArchiveEntry is { relative_path, data, pkg_type, repo }, with no compressed field.

So, on whether the list needs to become dynamic or content-sniffed for gunnar. Under P-005 the answer is yes, but the sniff is trivial and gunnar already knows the answer. A packfile is deflated; a loose object is zlib-wrapped; the pack-entry bytes P-005 proposes storing are deflated by construction. gunnar does not need to detect anything; it needs a way to tell znippy what it already knows. A general content sniffer (entropy estimate over a prefix, or a zlib-header magic check for 0x78 0x01/0x9c/0xda) is the wrong shape here: it re-derives, heuristically and fallibly, a fact the caller holds with certainty.

P-017 — LFS: chunking, and whether gatling's balanced map fits it

Chunk size: take znippy's, do not invent one. znippy already splits files above file_split_block_size = 10 MiB into rows sharing a relative_path, ordered by chunk_seq and placed at fdata_offset (§4 records this). An LFS object is exactly the shape that was built for: large, immutable, content-addressed. Reuse it (LAW 5): an LFS object becomes a znippy entry keyed by its oid, chunked by znippy, with per-chunk blake3 verification for free. No second chunking layer.

Do not reach for content-defined chunking (rolling hash / FastCDC). It exists to deduplicate near-identical large files across versions. LFS objects are already content-addressed and deduplicated by oid at the protocol level; CDC would add real complexity to deduplicate something the protocol has already deduplicated.

gatling_map_balanced fits, cleanly, but for the batch tier, not per request (P-004):

Transport, unchanged from §12: git-lfs 3.0's pure-SSH git-lfs-transfer protocol, same session, same auth, no HTTP server. Verify against the spec before committing; §5's exec dispatch must take a third command as one match arm.

P-018 — The gix output pipeline's serial tail — sort, reorder, hash — is one thread by construction

Serial stages, all READ:

  1. Counting's reduce. reduce::Statistics::feed runs on the calling thread and does self.counts.extend(counts) (reduce.rs:33). At 1 M objects that is a single-threaded Vec grow and memcpy of 1 M Count structs.
  2. The sort. counts.sort_by on (pack_id, pack_offset) (iter_from_counts.rs:108-117) is fully serial: ~20 M comparisons with a branchy comparator over Option<Location> at 1 M counts, followed by a serial partition_point scan (:119-130).
  3. enough_counts_present = counts.len() > 4_000 (:73): below 4001 counts the pack-location resolve is fully serial, silently. Most fetches are below that.
  4. The reorder. Workers return chunks out of order tagged with a SequenceId; InOrderIter buffers them in a BTreeMap (in_order.rs:10, 50) until they can be emitted in sequence. One slow chunk head-of-line-blocks the writer and inflates memory by every completed-but-not-emittable chunk.
  5. The hash and the back-references. FromEntriesIter writes through gix_hash::io::Write (bytes.rs:24), which hashes every byte on the consuming thread (gix-hash/src/io.rs:99-103), and maintains pack_offsets_and_validity for delta back-references (bytes.rs:74, 111-117). The back-references are why the tail must be serial and in order. Not an oversight.

So the tail — reorder, back-reference, hash, write — is one thread, by construction, and it is the ceiling on packs/second regardless of how many cores the counting and encoding stages get. P-008's hash fix acts directly on this thread, which is the second reason it matters more than its 3%-of-today figure suggests.

Measured: the tail is nowhere near the constraint on a serving request, and the reason is that gunnar deliberately does not use the pipeline's parallel half at all. crates/gunnar-pack/src/copy.rs passes thread_limit: Some(1) (P-020, P-004), so the whole generator is one thread and there is no reorder buffer to head-of-line-block. On that shape gunnar.clone_large serves 6144 copied entries at 200.11 MB/s against 20.19 MB/s for the same objects re-deflated. The tail moved from irrelevant to dominant relative to the rest of the request, and is still an order of magnitude faster than the writer it replaced. Stage 3 of the list above (counts.len() > 4_000) therefore has no effect on gunnar's serve path either way, which removes the "silently serial below 4001 counts" concern from that path entirely. It is serial above it too, on purpose.

Measured: this tail is what the remaining head-to-head gap is made of. After P-001, gunnar emits byte-identical output to git upload-pack for 16.2× the server CPU (0.378 s against 0.023 s, vs_git_clone, 2026-08-03). git reads a contiguous region of one pack guided by reachability bitmaps; gunnar reassembles entry by entry through the stages above and hashes every byte on the consuming thread. So the entry's claim that this bounds P-001 is right, and the bound is not on P-001's win; it is what is left after it.

core_saturation: MEASURED 2026-08-03, and the gate input is wrong. telem_cores_busy_max reads the whole host and returns 32 of 32 for the strictly single-threaded walk_1m arm, whose process actually occupied 0.56 of one core. It cannot go red, so it cannot answer this entry's question either. The number that can is the process's own task-clock ÷ wall; for this entry's subject — whether pack serving peaks at one core — that reading is available today from perf stat with no code change. See the core_saturation bullet under P-015 for the measurement and for the one-field fix in nornir-bench.


P-025 — The serve path inflated every object in the repository to find out how long its entry was

buf.resize(size, 0);                                   // zero the inflated size
pack.decompress_entry(&entry, &mut inflate, buf)       // inflate the WHOLE object
    .map(|entry_size_past_header| Location {           // keep only the LENGTH
        pack_id, pack_offset,
        entry_size: entry.header_size() + entry_size_past_header,
    })

The inflated bytes are then thrown away. On P-001's copy path they are never wanted at all — the entry goes onto the wire still compressed — so a clone inflated the entire repository in order to compute byte ranges it was about to memcpy. It is not gix's defect: location_by_oid has to answer for callers that have nothing but the pack data. It is gunnar's, for asking that way.

MEASURED oden 2026-08-04, perf record -F 4999 over a whole-repository clone of znippy.git through stage-probe fetch, taskset -c 0-7:

before after
zlib_rs::inflate* 51.3% of the profile 6.1%
sha1::compress (the trailer, irreducible) 12.95% 25.04%
page faults, inclusive 15.19% —
perf event count 287.1 M cycles 130.1 M

Two cases decline and fall back to the inflate, at one object each per pack: the pack's last entry (no successor to measure against; the trait cannot say how long the pack file is), and the first lookup into a pack (the location gix resolves is the only way to learn a pack id through the trait). MEASURED across the corpus: the index answered 99.94% of a clone's locations at the median, 99.66% of a fetch's.

A pack whose offsets are not strictly ascending is refused outright rather than sized wrong.

So PackedObjects mirrors gix's front index and answers only for that pack. gix-odb searches index 0 first, so if the object is in the front pack gix returns the front pack, whatever its MRU history; if it is not, this declines and gix does its own search, and its answer becomes the new front here exactly as it becomes index 0 there. Byte-identity is then by construction rather than by fixture. tests/pack_extents.rs::overlapping_packs_do_not_let_a_hash_seed_choose_the_bytes is that fixture, and removing the front rule fails it 4 times out of 4.

Nothing in the corpus is multi-pack, so this cost nothing there: the corpus table above is the same to within noise before and after the rewrite (clone median 2.079× → 1.965× across two runs an hour apart on a box whose load ran 9–24).

The list is now held to the pack's own 12-byte header, the one object count no index can filter. MEASURED oden 2026-08-05: 48 surplus bytes on a 287 691-byte two-pack fixture; 46 080 on knut.git at 16 packs (8 066 803 refused → 8 020 723 accepted, 0.57%). The refusal is per pack, so P-025 survives where the index left a list whole: 82.0% of that clone's locations still came out of an index, against 89.1% before, a loss of 230 of 2915 lookups (7.9%). objects_copied_from_pack/objects_recompressed were 1559/1714 on both arms — what changed is how a length is learnt, never whether an entry is copied.

Pack count was never the trigger; an object in two packs is. bench/year clones clean at 1139 packs because its packs share nothing, and the floor here is two — the fewest a multi-pack index can span. MULTI_PACK_INDEX_THRESHOLD = 16 only decides when a store starts having the index that exposes it, which is why the shipped 12-pack corpus never showed it although 19 of 19 of its repositories hold at least one duplicated object (1 funnel-core … 380 nornir) — tunnr, the sha256 member, among them at 13. The 99.94% figure above therefore stands only for a store with no multi-pack index.

median range corpus total
clone (64 528 objects, 94.7 MB wire) 1.965× 1.288–2.736× 873.7 → 503.0 ms, −42.4%
negotiated fetch (5 827 objects, 12.2 MB) 1.663× 1.111–2.461× 190.4 → 137.9 ms, −27.6%

Noise band: the worst per-row CPU spread across the five repetitions was a median of 6.9% (clone) and 7.1% (fetch). Two rows are much worse and are named rather than averaged away: znippy-zoomies fetch, 64.8% on the disabled arm at 3.96 ms, and korp fetch, 21.8% on the enabled arm. Every clone row is outside its own band. The weakest row in the table is korp's fetch at 1.111×, and korp is the corpus's large-binary-blob member: its 42 ms of CPU is mostly not this.

Peak RSS, /usr/bin/time -f %M, znippy.git clone, three runs per arm: 69 288–70 204 kB → 54 580–54 744 kB, −21.4%.

Persisted, nornir bench run gunnar → bench_runs, gunnar.clone_large (a synthetic 2048-commit churn of one large file, 6144 objects, 67.69 MB out — not the corpus, and the ratio is much larger because the objects are much larger): 197.71 / 205.05 / 208.92 MB/s → 914.01 / 1099.56 MB/s, i.e. 4.4–5.6×, clone_ms 309–327 → 58.7–70.6. bytes_out, objects_copied_from_pack (6144) and objects_recompressed (0) are identical on both arms, which is a fourth independent witness that nothing moved. Run ids: off 4cfb4708 adc6b38d e9bf0adc, on 9c3243dc 34f0c033. Re-run after P-027 made nornir-bench's own Counting<S> forward bitmap(), in case that arm had been on the walk path: 199.85 / 209.76 → 1037.40 / 1045.93 MB/s, unchanged, because that fixture's linear history carries no .bitmap for either arm to use (82f4d9b6 63476c27 off, 2a95c674 bd0514cf on).

One run of this arm — 4b65a303, 187.19 MB/s — is in bench_runs against another lane's worktree: nornir bench run gunnar resolved the repository by name and built /home/rickard/scratch/wt/gunnar, not this branch. Recorded because the row is in the warehouse and is not a measurement of anything named here. The fix is --config <dir>/nornir.toml with [repo.gunnar] path naming the worktree, since the repo root is resolved from the config's grandparent joined with path.

Stage decomposition (stage-probe fetch, nornir.git, 12 186 objects): the whole saving lands on the calling thread, because the resolve phase is already inline at thread_limit: Some(1) — pack_write exclusive CPU 61.84 → 16.73 ms (3.70×), and its share of the attributed request fell from 86.28% to 63.22%, leaving select.bitmap as the second real cost at 36.78%.

Seen RED five ways, 2026-08-04:


P-026 — P-001's headline is a synthetic fixture's; on real repositories it is 3.4× the bytes and 18× the CPU, not 18× and 220×

Two things it is NOT. It is not contaminated by the bitmap() wrapper defect: ObjectStore::bitmap landed at ffb09f3, 2026-08-04 16:05, a day after that measurement, so neither column had a bitmap. And it is not a NoPackCopy A/B at all — it is gunnar-before-P-001 against stock git across two commits. Both of those were worth checking before correcting anything, and the correction is narrower for it.

What is contaminated is every BEFORE column taken through gunnar_store::NoPackCopy between 16:05 and b3bf145 at 20:47 the same day — GUNNAR_BENCH_NO_PACK_COPY=1, crates/gunnar-wire/tests/pack_copy.rs — because that wrapper did not forward bitmap() and the trait default is None. See P-027.

bytes on the wire server CPU (P-001 alone)
clone, median of 19 3.401× (1.325–10.952×) 18.05× (4.68–50.30×)
clone, corpus total 420.9 → 94.7 MB, 4.445× 20.79 → 0.91 s, 22.80×
fetch, median of 19 2.786× (1.302–12.980×) 11.53× (4.72–79.68×)
fetch, corpus total 40.2 → 12.2 MB, 3.295× 2.00 → 0.19 s, 10.74×

With P-025 on top the CPU column becomes 33.52× (clone, median) and 18.65× (fetch, median) against the same BEFORE column; the byte column is unchanged because P-025 is byte-identical by construction.

So the shape of the claim survives and its size does not. Pack-copy is worth 3.4× the bytes and 18× the CPU on a median real repository. That is a large win and it is not the one that was being quoted.


P-027 — Five ObjectStore wrappers turned an optimisation OFF by not mentioning it, and no wire byte differed

Nothing could catch it. The bitmap path is defined to select the same object set as the walk, so wire bytes, object counts and every fidelity digest across all 19 corpus repositories agree either way. That is the definition of a change no test can see.

Five instances, all live on 2026-08-04:

fixed at what it was the instrument for
gunnar_bench::stages::StageCounting 468db46 the whole stage decomposition
gunnar_store::NoPackCopy b3bf145 P-001's BEFORE column
gunnar_bench::readside::Counting b3bf145 gunnar.fetch_readside
nornir-bench's own Counting<S> this entry gunnar.clone_large, gunnar.vs_git_*
gunnar_bench::have_lookup::IndexedContains this entry gunnar.oid_index's have-lookup arm

The last two were found by the rule below, not by reading. A sixth, gunnar_store::conformance::BrokenObjects, forwarded neither and would have made the conformance red probes run against a store that was also missing two optimisations.

The rule reads a wider file set than the other two — crates/*/tests and bench/ included — because three of the five instances were instruments rather than servers, and an instrument is exactly where a silently-disabled optimisation becomes a published number.

P-028 — A have that misses costs a statx, because one contains serves two callers with opposite correct answers

caller a miss means a stale false costs
a want, a delta base, an id the closure walk already found my snapshot is stale a refused fetch
a client's have the client is naming its own commits a wider pack

Negotiation is the second row and it is the common case: a have the server does not hold is the expected answer, so the syscall is spent to re-derive a false that was already correct. git draws the same line — has_object() passes OBJECT_INFO_QUICK and re-scans only under HAS_OBJECT_RECHECK_PACKED.

shape haves misses statx before → after negotiate before → after stage saved per miss whole request its noise band
behind16 — the shape that dominates real traffic 16 0 0 → 0 194.4 → 197.7 µs 0.98× — 0.98× 1.32
diverge128 — 200 commits behind, the real mix 128 107 107 → 0 1530.8 → 1475.4 µs 1.04× 518 ns 1.01× 1.26
allmiss128 — the ceiling 128 128 128 → 0 317.3 → 220.7 µs 1.44× 755 ns 1.01× 1.30

Across all four runs the saving per miss is 440–790 ns — one statx — and the allmiss128 stage ratio is 1.43–1.44× every time.


S-003 — where a server-side delta encoder belongs

Moved here from the front page 2026-08-05.

The client computes deltas; repack() preserves them and the serve path computes none (Mode::PackCopyAndBaseObjects, crates/gunnar-store/src/repack.rs). MEASURED 2026-08-04, 9 corpora pushed by stock git into a bare repo, --thin against --thin --window=0: a serve-time encoder is worth 1.14×–4.90× freshly pushed and exactly 1.0000× after gc, 9 of 9. So the encoder belongs in the batch tier and not the serve path, and the pack is the store. Pack-copy already recovers 100% of a clone and of a fetch (gunnar_pack::write_pack_copy_thin).

Amended 2026-08-05 — the thin voucher meant visited, and one receiver noticed

Selection::client_has vouched for what the client holds plus the objects being sent, on the argument that naming an in-transfer base is an OBJ_REF_DELTA against an object in the same pack and stock git accepts that. Stock git does. gitoxide does not: gix_pack::data::input::LookupRefDeltaObjectsIter resolves every OBJ_REF_DELTA of a thin pack against the receiver's object database and the bases it has already spliced in, and never looks inside the pack it is reading. gitoxide is what gunnar-client and receive-pack both end in, so gunnar was serving packs its own client could not read.

It needs a multi-pack store to happen at all: the base is stored twice — whole in the pack whose push introduced its dependant (spliced there by index-pack --fix-thin), and as a delta in the pack whose push introduced it — so gix_pack's copier cannot re-point the second delta and names its base by object id. One pack, one location, no such entry. That is why every arm in crates/gunnar-wire/tests/pack_copy.rs was green: its fixture is git repack -adf, one pack. A repository that has been pushed to and not gc'd is the shape, and it is also exactly the repository with no .bitmap — so this was the walk path and never the bitmap path.

Found by gunnar.year, which is the only arm that pushes concurrently and then fetches: it died at week 3–7 on The object <tree> could not be decoded or wasn't found.

The voucher is now the exclusion closure alone, on both paths. MEASURED on oden 2026-08-05, one negotiated fetch per repository per cut over the 19 real workspace_nordisk repositories (bifrost included — the corpus mirror of it is intact, whatever its working checkout is), rebuilt in the production shape (12 pushes each, 12 packs, no .bitmap, sha1 and tunnr's sha256), 46 cases, both judges on the same bytes:

gitoxide index_thin_pack stock git index-pack --strict --fix-thin + fsck --full --strict
before (13aa979) 13 of 46 FAIL 46 of 46 pass
after 46 of 46 pass 46 of 46 pass

The 13 failures are 11 of the 19 repositories, at both hash kinds.

What it costs. On the same 46 cases, fetchbytes-probe: thin bases refused 4 → 74 (Edges) and 0 → 73 (Full), objects re-deflated 897 → 967 (+7.8%), and packfile wire bytes +0.532% (Edges) / +0.542% (Full). None of the new refusals is wrong in the sense the probe judges: the base is an object of the transfer, which the receiving repository genuinely does not hold before the fetch.

And it costs nothing where there is nothing to refuse. On the shipped real-corpus (one repack -adq pack and a .bitmap each) all five arms are byte-identical before and after, 46 of 46 cases, pack_wire unchanged to the byte — bitmap arms included, and answered_from_bitmap is 46/46 there. The walk's own extra work is one Vec<ObjectId> push and one hash removal per object sent, which is O(objects in the transfer) against a walk that already reads and deflates every one of them. Isolated on scale-deep-1500c-256k-20d with GUNNAR_BITMAP=off, 9 interleaved runs pinned to 4 cores: same 76 objects, same 71 store reads, same 4 987 665 wire bytes, serve_ms median 6.99 → 7.03 (+0.57%) inside an 8.5% / 5.5% spread. Below the instrument floor and not claimable as a cost.

Amended 2026-08-05 — the unselected base was decided by offset, and a multi-pack store has two

WriteOptions::base_closure names a delta's unselected base and adds it to the pack rather than exploding the delta. It decided "unselected" by pack offset, which is the question gix_pack's Entry::from_pack_entry asks — it searches potential_bases for a matching (pack_id, pack_offset) — and it is the wrong question to ask of a pack. A pack may hold an object once, and that is a question about an object id.

On a single-pack store the two are the same question. On a multi-pack store they come apart, and it is the same duplication the amendment above turns on: an object stored in two packs is one id at two locations, location_by_oid answers with one of them, and a delta in the other pack names an offset the selection does not hold whose object it does. The closure added it, and git index-pack refuses such a pack outright — fatal: The same object <id> appears twice in the pack.

Found by gunnar.year again, at week 12, on the quarterly stock-git clone. It is a CLONE: no common, thin == false, client_has never consulted, so it is not the voucher and the two defects are one after the other.

MEASURED oden 2026-08-05, one real git clone --single-branch per repository with transfer.fsckObjects=true followed by fsck --full --strict, over the same production-shaped corpus (12 pushes, 12 packs, no .bitmap, sha1 and tunnr's sha256), 18 repositories (bifrost excluded: its SOURCE pack fails git fsck):

closure on closure off
before (8f79cd7) 13 of 18 REFUSED 18 of 18 pass
after 18 of 18 pass 18 of 18 pass

The 13 are all sha1. tunnr, the corpus's one sha256 repository, is green in both arms before and after — its production-shaped store does not contain the condition — so sha256 here is a non-regression and not a reproduction. The key that changed is an ObjectId and nothing in the fix is hash-width dependent, but that is an argument and it is recorded as one.

The closure now declines a base the pack already carries, counted as WriteOutcome::closure_bases_declined, and declines the whole chain with it: the entry that collided is the one the delta above it names, so nothing above it could be copied either.

It costs none of the item. On the shipped real-corpus the served packs are byte-identical before and after in both arms, 18 of 18, and the unselbase-probe table reproduces to the object: 64 445 → 64 533 objects (+0.14%), 94 575 097 → 91 903 865 wire bytes (−2.82%), 448 → 320 re-deflated, server CPU −52.78% at 8f79cd7 and −52.33% here (5 repetitions each, best-of, 4 pinned cores on a box at load 6.3). The negotiated-fetch path is untouched by construction and measured so: 55 cases across 19 repositories × 3 cut depths, both judges, identical output.

Why no fixture in the tree could fail on it. git clone --bare repacks. Every fixture built that way holds each object once, one object has one location, and the two membership questions cannot come apart — the same trap the amendment above turned on, one defect later. crates/gunnar-wire/tests/pack_copy.rs now carries the arm, and its DUP_FILES doc carries the pack-count sweep that says the condition needs five packs before it appears at all.

A separate defect the sweep uncovered, and it is NOT this one. On this fixture, write_pack_copy emits a pack both receivers refuse the moment the store has a multi-pack index — stock git with fatal: pack is corrupted (SHA1 mismatch), gitoxide with a failed index — where NoPackCopy is clean on the same bytes and GUNNAR_BASE_CLOSURE=off makes no difference. closure_bases_declined also falls to zero there, so the closure stops seeing the store at all.

The multi-pack index is the cause and not the co-ordinate, and that is measured rather than inferred: FsStore::open_or_init writes one at gunnar_store::midx::MULTI_PACK_INDEX_THRESHOLD packs, and moving that constant from 16 to 8 moves the cliff from 16 packs to 8, exactly.

  threshold 16   packs 15 ok · 16 FAIL · 17 FAIL
  threshold  8   packs  7 ok ·  8 FAIL ·  9 FAIL

What is not claimed: that every multi-pack-index store is affected. gunnar.year runs to 1 139 packs on this commit and clones clean, so the shape needs more than a midx. MEASURED at sha1 only — this fixture builder has no sha256 arm. The item is open.

Appendix — reproducing the measurements

The probe is at /home/rickard/scratch/cargo/gunnar-perf-probe/ (scratch on md1, not in this repo, and deliberately disposable; P-015 is the real harness). Two binaries:

cargo build --release                 # flate2 default => miniz_oxide
cargo build --release --features zrs  # flate2/zlib-rs
./target/release/gunnar-perf-probe    # SHA-1, deflate, inflate, allocation shape
./target/release/scale                # deflate thread scaling, 1..64 threads

Everything it measures is a component. No end-to-end gunnar operation has been benchmarked by anyone, and this file does not claim one has. That is S-044, and P-015 is the answer to it.

The P-007 / P-008 closing probe (2026-08-03)

The numbers in those two closed entries come from a second probe, at /home/rickard/scratch/cargo/gunnar-p007-p008-probe/, also disposable. It differs from the one above in the way that matters: it path-depends on gunnar-pack and drives the real PackWriter, so what it reports is a whole pack write — entry headers, deflate, trailer hash — not a raw flate2/gix-hash loop.

cargo build --release
./gunnar-p007-p008-probe [corpus-dir] [byte-cap]   # default: gunnar's own crates/

Method, and its limits:

The permanent guards these numbers justify are crates/gunnar-pack/tests/deflate_backend.rs and crates/gunnar-pack/tests/trailer_hash.rs. They are correctness guards, not benches: they pin bytes, not milliseconds. P-015 is still the missing piece for timings.

The P-001 before/after (2026-08-03)

No probe crate at all this time, and that is the point: P-015's harness is now the instrument, and the BEFORE column is produced by the same binary as the AFTER.

cd bench
podman build -t gunnar-bench-git:2.53.0 .          # once
NORNIR_MACHINE=oden NORNIR_BENCH_ONLY=gunnar.vs_git \
  cargo run --release --example nornir-bench        # AFTER

NORNIR_MACHINE=oden-preview GUNNAR_BENCH_NO_PACK_COPY=1 \
NORNIR_BENCH_ONLY=gunnar.vs_git \
  cargo run --release --example nornir-bench        # BEFORE — arms go RED

Method, and its limits:

The permanent guards these numbers justify are gunnar.clone_large's copy-split and blob_reads == 0 assertions and h2h_assert_delta_reuse on both head-to-head clone arms, all in bench/examples/nornir-bench.rs, plus xtask/tests/pack_pipeline.rs for P-020's two source markers.

Re-measured 2026-08-03 17:09 UTC, after the pack_copy repair

Both columns re-run from the same binary at 569682b, NORNIR_BENCH_ONLY=gunnar.vs_git, against stock git 2.53.0 in localhost/gunnar-bench-git:2.53.0.

arm objects wire BEFORE wire AFTER pack_ratio_x BEFORE → AFTER server CPU BEFORE → AFTER
vs_git_clone (sha1) 36 172 108.70 MB 5.93 MB 18.336× → 1.000 4.777 s → 0.404 s (11.8×)
vs_git_clone_sha256 36 172 115.06 MB 6.49 MB 17.723× → 1.000 4.938 s → 0.392 s (12.6×)
vs_git_clone_concurrent 52 112 193.14 MB 26.75 MB 7.220× → 1.000 7.200 s → 0.710 s (10.1×)

gunnar_copied / gunnar_recompressed on the AFTER clone arms: 36 172 / 0 and 52 112 / 0. That split, and not the byte count, is what says these are copies.

This box was NOT idle, and the section above is the reason the distinction is written down rather than assumed. Load average was 17.7 rising to 28.1 on 32 cores across the two runs (determinism-8ea at 713% CPU, errcode_raise_s at 384%, osmium at 125%), and the harness's own telemetry agrees: telem_cores_busy_avg 15.2 on the AFTER clone arm, 32.0 on the concurrent one. So:

The red switch was re-proven in the same sitting, which mattered this time: NoPackCopy moved out of the bench and into gunnar-store at 569682b so that gunnar-wire/tests/pack_copy.rs could cut the seam at the same place (LAW 5), and a switch that had stopped reaching the server would have shown up as a BEFORE column that quietly matched the AFTER one. It did not: all three arms went red with the figures above, and vs_git_clone_concurrent — the arm that once stayed green through a red run because the switch missed its long-lived daemon — went red with the rest.


P-029 — the ~1 500 ns P-028 could not attribute is two syscalls in gix-odb, and neither is what it named

Closes the open question P-028 left behind. Its words were: "the ~1 500 ns still standing is neither the refresh nor the index: it is the pool lease, the gix-odb handle and Exists::exists itself." That is refuted. Those three together are at most a fifth of it. Arm: a standalone contains_without_rescan probe against a real mirror, 2026-08-04, oden.

syscall calls per miss
statx 20 046 (20 002 ENOENT) 1.00
sched_yield 20 001 1.00

The same binary asked 12 328 ids that are present issues neither. Both are miss-only, and a syscall count is exact whatever the box is doing, which is why it is the primary evidence here and the nanoseconds are the secondary.

arm ns per miss what it removes
A — stock 1045.1 —
B — first yield_now() gone 783.8 sched_yield
C — B, plus the loose lookup stubbed out 197.9 PathBuf + statx
term ns share of a miss
loose PathBuf clone + statx 585.9 56 %
sched_yield 261.3 25 %
everything else — pool lease, handle, arc_swap, RefCell, fanout + bsearch 197.9 19 %

Arm C is wrong as a shipped change and exists only to size the term; the syscall counts confirm each arm removed exactly what it claims (B: 0 sched_yield, 20 046 statx; C: 44 statx, all of them process start-up).

Replicated at a different load, because a three-way split on a box at load 200 is a claim that has to survive being taken twice. Second run at loadavg 131: 1081.5 / 797.8 / 199.2 ns, giving 283.7 ns (26 %) / 598.6 ns (55 %) / 199.2 ns (18 %). Totals agree within 3.5 % and every share agrees within one percentage point.


P-030 — every push paid 50 ms of sleeping, and no gunnar-side lever existed

FIXED, carried as the one patched dependency in the tree. vendor/gix-features + [patch.crates-io]; the diff is +30/−2 lines in one function. Red-first proof: crates/gunnar-pack/tests/index_floor.rs. Upstream report: .nornir/upstream-gix-slice-watcher-sleep.md. 2026-08-04, oden.

stock patched
min 50.22 ms 2.99 ms
p10 50.25 ms 5.11 ms
p50 50.32 ms 10.98 ms
max 55.01 ms 43.08 ms
samples below 45 ms 0 / 100 100 / 100
process CPU (getrusage, all threads) 3.0–5.2 ms 3.2–5.4 ms

The flat before-column is the evidence. Those samples were taken while 32 cores carried a load average of 317, and the spread is 50.22–55.01 ms, because the sleep dominates everything the machine is doing. The after-column's spread IS that load, now visible. CPU is unchanged: a wait was removed, not work.

round stock patched Δ
1 114.70 ms 89.21 ms −25.5
2 127.65 ms 65.48 ms −62.2
3 71.16 ms 34.37 ms −36.8
4 70.10 ms 18.90 ms −51.2
5 66.84 ms 35.99 ms −30.9
6 97.36 ms 72.96 ms −24.4

Best conditions either arm saw: 66.84 → 18.90 ms, 3.54×, Δ −47.9 ms — the floor, and essentially nothing but the floor. Every round favours the patch; the spread between rounds is the fleet, not the change. The stored pack is byte-identical in all 12 runs and all 372 pushes: sha-256 f7ceed18….

round git index-pack gunnar, unpatched gunnar, patched
1 2.44 ms 50.31 ms — 20.6× git 0.82 ms — 0.34× git
2 2.17 ms 50.46 ms — 23.3× git 1.95 ms — 0.90× git
3 2.08 ms 50.38 ms — 24.2× git 1.02 ms — 0.49× git

And across the size range, interleaved the same way, minimum of nine each, at a quieter loadavg of 64:

objects git index-pack gunnar, unpatched gunnar, patched
8 2.08–2.44 ms 50.3–50.5 ms — 20.6–24.2× git 0.82–1.95 ms — 0.34–0.90× git
1 007 124.18 ms 140.86 ms — 1.13× git 123.71 ms — 1.00× git
12 021 1612.72 ms 661.53 ms — 0.41× git 586.56 ms — 0.36× git

The floor helps at every size and hurts at none, and CPU is flat throughout — 12 021 objects: 1088.8/1095.1 ms of process CPU stock against 1081.2/1074.3 patched, inside 2%. gunnar was already ahead of git at 12 000 objects before the patch; what the patch changes is the small end, which is where the ordinary push lives.

The stated ratio needs its caveat and it is not a small one: git's column is a whole subprocess, fork and exec included, and gunnar's is an in-process call. The comparison is fair as a server comparison — stock git's receive-pack forks index-pack and gunnar does not — but it is not a like-for-like of the two indexing implementations, and anyone quoting "gunnar is twice git's speed at indexing" from this row is quoting it wrong. What the row does establish without any caveat is the unpatched column: 20.6–24.2× is not a process-startup effect at that magnitude.