Negotiated transport encoding for gunnar: measured, and rejected
Opened 2026-08-03, against master at 87524d6 ("Admission control, and a measured decision not to parallelise"). Companion to .nornir/performance-design.md, which is owned by another agent: nothing here edits that file, or any crate. New entries use the same block format and continue its numbering at P-019, so they merge unchanged.
The answer, first
Do not build the capability. P-001 makes it unnecessary, and on the largest real corpus measured here pack-copy beats every negotiated alternative on both axes at once.
On a real 74,523-object repository, serving the whole thing:
| bytes on wire | server CPU | |
pack-copy (P-001), no capability, stock git can read it |
70,028,538 | 25.30 ms |
| best negotiated arm at comparable CPU (raw objects + zstd-3) | 103,168,818 | 2,039.29 ms |
| best negotiated arm at any CPU (raw objects + zstd-9) | 72,551,454 | 3,940.52 ms |
| today (per-object zlib L6) | 627,387,423 | 28,609.15 ms |
Raw-objects-plus-one-zstd-stream is the right idea and it does beat what gunnar does today by a wide margin: 6.1× fewer bytes and 14× less CPU than the current writer. P-001 simply beats it by more, for free, without touching the format or asking anything of the client. Spending zstd's CPU to approach a number pack-copy reaches with a memcpy is the whole result.
There is exactly one arm that sends fewer bytes than pack-copy: pack-copy's own content, undeflated, through one zstd stream (A6 below). It buys 9.2% on the large corpus for 39× the server CPU and a mandatory client-side transcode. At the load target — thousands of concurrent clones on one box, where serve CPU is the thing that saturates — that trade runs backwards. It is recorded as P-019 and marked WONTFIX rather than TODO.
And the third layer's premise turns out to be the weakest part. A capability only pays when both ends are gunnar. Stock git is the client in nearly every request a forge serves, and stock git can consume pack-copy output unmodified. The capability's addressable share of traffic is the share where the client is also gunnar, which is small, and on that share it loses anyway.
What was measured, and on what
Machine. oden, an AMD Ryzen Threadripper PRO 3975WX. nproc = 32, sha_ni/avx2/ bmi2 present. Every number in this file is single-core, pinned with taskset, best-of-N after a warm-up (N = 7 for the two smaller corpora, N = 3 for the large one). Measured 2026-08-03. The box was shared with other agents during the run; best was used rather than mean for exactly that reason, as P-008 did.
Probe. A standalone throwaway crate at /home/rickard/scratch/cargo/gunnar-transport-probe/, pinned to the exact versions gunnar's Cargo.lock resolves (gix-pack 0.73.0, gix-odb 0.83.0, gix-object 0.63.0, gix-hash 0.26.0, flate2 1.1.9 with zlib-rs), plus zstd 0.13.3 and lz4_flex 0.13.1. --release, opt-level = 3. It is a probe, not the bench suite; P-015 still owns the historized one. No crate in /home/rickard/git/gunnar was modified.
Corpora. Real git objects out of real repositories, cloned --bare --no-hardlinks into scratch and git repack -adf --window=250 --depth=50'd, i.e. exactly the shape a client's git push deposits in the incoming tier.
| corpus | source | objects | raw content | pack on disk |
| small | /home/rickard/git/gunnar |
690 | 7.62 MiB | 1.41 MiB |
| mid | /home/rickard/git/nornir |
12,305 | 325.51 MiB | 7.25 MiB |
| large | /home/rickard/.hermes/hermes-agent |
74,523 | 2,189.55 MiB | 66.79 MiB |
The ground-truth check that makes the pack-copy row believable. git clone --bare of each corpus, by stock git, produces packs of 1,475,754 / 7,600,737 / 70,028,558 bytes. The A5 pack-copy arm below produces 1,475,734 / 7,600,717 / 70,028,538, twenty bytes fewer in each case, which is the SHA-1 trailer the probe does not append. The pack-copy row is therefore not a model of what git would send; it is byte-for-byte what git does send, measured against coreutils-grade external truth rather than against the probe's own arithmetic.
What the CPU columns include and exclude. Codec work only: deflate/inflate, zstd, lz4, and the memcpy that assembles a stream. The graph walk, object lookup, syscalls and the network are excluded, as performance-design.md's CPU budget excludes them and for the same reason. So is the trailer hash, P-008's subject, ~0.17 ms/MiB of output on every arm.
Every arm is round-trip asserted before it is timed. A3/A4/A6 assert the decompressed stream is byte-identical to the input; A1 asserts the inflate recovers exactly raw content bytes. An arm that lost data would abort rather than post a flattering number. This mattered: it is the only thing standing between "one zstd stream" and a plausible-looking wrong answer.
The three layers, kept separate
- Wire to stock git. Zero freedom. Each object in a packfile is an independent zlib stream. This is the format. Nothing below proposes changing it, and the winning arm does not touch it.
- At rest, the cold tier. Total freedom, and out of scope here; see
P-005and the unresolved §4 note, which this file does not attempt to settle. - Gunnar ↔ Gunnar. Negotiable. The subject of this file, and the layer where the measurement says do nothing.
The measured table
Small corpus, gunnar: 690 objects, 7.62 MiB raw content.
| arm | bytes on wire | ratio | compress CPU | decompress CPU |
today: per-object zlib L6 (zlib-rs) |
2,889,059 | 2.77× | 107.91 ms | 12.05 ms |
| today, at L1 | 3,965,731 | 2.01× | 32.05 ms | 12.36 ms |
| zstd-1 over the finished packfile | 2,877,818 | 2.78× | 108.47 ms | 12.13 ms |
| zstd-3 over the finished packfile | 2,797,649 | 2.86× | 110.26 ms | 12.14 ms |
| zstd-9 over the finished packfile | 2,800,999 | 2.85× | 112.98 ms | 12.16 ms |
| raw objects + one zstd-3 stream | 1,808,260 | 4.42× | 22.02 ms | 4.37 ms |
| raw objects + one zstd-9 stream | 1,302,180 | 6.13× | 64.94 ms | 2.99 ms |
| raw objects + one lz4 stream | 3,896,873 | 2.05× | 16.72 ms | 3.10 ms |
pack-copy (P-001) |
1,475,734 | 5.41× | 0.05 ms | 0 ms |
| A6: pack entries raw + one zstd-3 | 1,284,279 | 6.22× | 18.93 ms | 2.62 ms |
Mid corpus, nornir: 12,305 objects, 325.51 MiB raw content.
| arm | bytes on wire | ratio | compress CPU | decompress CPU |
| today: per-object zlib L6 | 98,028,776 | 3.48× | 4,279.11 ms | 380.72 ms |
| today, at L1 | 139,397,943 | 2.45× | 1,077.66 ms | 421.55 ms |
| zstd-3 over the finished packfile | 72,265,771 | 4.72× | 4,566.54 ms | 429.30 ms |
| zstd-9 over the finished packfile | 69,617,715 | 4.90× | 5,038.38 ms | 432.24 ms |
| raw objects + one zstd-3 stream | 11,996,845 | 28.45× | 343.51 ms | 146.95 ms |
| raw objects + one zstd-9 stream | 7,468,173 | 45.70× | 756.83 ms | 142.72 ms |
| raw objects + one lz4 stream | 119,234,280 | 2.86× | 761.93 ms | 215.24 ms |
pack-copy (P-001) |
7,600,717 | 44.91× | 0.98 ms | 0 ms |
| A6: pack entries raw + one zstd-3 | 6,255,991 | 54.56× | 133.87 ms | 17.71 ms |
Large corpus, hermes-agent: 74,523 objects, 2,189.55 MiB raw content.
| arm | bytes on wire | ratio | compress CPU | decompress CPU |
| today: per-object zlib L6 | 627,387,423 | 3.66× | 28,609.15 ms | 2,326.39 ms |
| today, at L1 | 883,493,001 | 2.60× | 6,760.03 ms | 2,573.61 ms |
| zstd-1 over the finished packfile | 504,191,848 | 4.55× | 29,003.37 ms | 2,535.30 ms |
| zstd-3 over the finished packfile | 438,554,115 | 5.24× | 30,491.35 ms | 2,643.95 ms |
| zstd-9 over the finished packfile | 408,463,458 | 5.62× | 33,552.97 ms | 2,656.34 ms |
| raw objects + one zstd-3 stream | 103,168,818 | 22.25× | 2,039.29 ms | 887.09 ms |
| raw objects + one zstd-9 stream | 72,551,454 | 31.65× | 3,940.52 ms | 735.08 ms |
| raw objects + one lz4 stream | 776,050,979 | 2.96× | 7,385.14 ms | 2,322.82 ms |
pack-copy (P-001) |
70,028,538 | 32.79× | 25.30 ms | 0 ms |
| A6: pack entries raw + one zstd-3 | 63,559,867 | 36.12× | 988.72 ms | 118.46 ms |
| A6: pack entries raw + one zstd-9 | 60,751,204 | 37.79× | 2,369.98 ms | 117.89 ms |
| A6b: pack entries raw + one lz4 | 77,943,345 | 29.46× | 694.65 ms | — |
Reading the rows
The naive arm loses, as predicted, and the number is worth having. zstd over the finished packfile buys 3.2% / 26.3% / 30.1% on the three corpora for +2.34 / +287 / +1,882 ms. On the small corpus it is the rounding error the hypothesis expects, because every object is already deflated and zstd sees entropy. On the two larger ones it is not negligible, but only because those packs contain many similar deflate streams, and what zstd finds is duplication between compressed entries, not inside them. It is a real 26–30%, obtained at 7–15% extra CPU on top of the most expensive arm in the table. Nobody should build it, but the "compressing entropy buys exactly zero" framing is too strong and the measurement says so.
The hypothesis is correct about the shape and still loses. Sending objects raw and zstd-ing the concatenation is dramatically better than what gunnar does today. On the large corpus that is 6.1× fewer bytes at 14× less CPU than per-object zlib L6. Cross-object redundancy really does become visible to the compressor for the first time, exactly as predicted, and the effect is large (mid corpus: 28.45× vs 3.48×). The problem is that git's own delta encoder already extracted that redundancy in the pack the client pushed, and P-001 ships it for a memcpy. Two mechanisms are competing to exploit the same structure; the free one wins.
lz4 is not competitive at any size and should not be reconsidered. It is the fastest compressor here and the worst by bytes on every corpus (2.05× / 2.86× / 2.96×), losing to plain per-object zlib on two of three. It decompresses fast, which is worth nothing when the winning arm decompresses nothing at all.
The interesting row is A6, and it is why the answer is "do not build" rather than "impossible". Taking P-001's pack entries, stripping the per-entry zlib wrapper while keeping the delta instructions, and running one zstd stream over the concatenation is the only arm that beats pack-copy on bytes: 13.0% / 17.7% / 9.2% fewer. It is the genuinely correct synthesis of the two ideas. Its cost is 379× / 137× / 39× the server CPU of plain pack-copy, plus a client transcode. Bad trade for a forge.
The safety boundary, and whether it eats the win
It does, and hardest for the arm people would actually reach for.
Everything in .git is zlib: loose objects are zlib, pack entries are zlib. A gunnar client that receives raw-encoded objects cannot store them; it must re-deflate to produce something stock git can read. Today that cost is zero: git index-pack writes the received pack bytes to .git/objects/pack verbatim. The transcode is a cost that exists only because the capability exists.
| corpus | A3/A4 transcode (deflate all content, L6) | A6 transcode (re-deflate entry payloads only) |
| small | 109.45 ms | 63.31 ms |
| mid | 4,332.09 ms | 320.90 ms |
| large | 30,091.57 ms | 3,625.01 ms |
For A3/A4 the transcode is by construction identical to arm A1. The client redoes, byte for byte, the deflate the server was celebrated for skipping. (The figures above come from a second probe run and differ from the A1 rows by 1.4–5.2%; that is this box's noise under other agents' load, and the two are the same operation.) On the large corpus that is 29.7 seconds of client CPU to save 2.0 seconds of server CPU. The win is not reduced; it is relocated, and made larger. A3/A4 are dead on this alone, before the byte counts are even consulted.
For A6 the transcode is genuinely much smaller and this must be said honestly: A6 ships delta instructions, so the client re-deflates only 122,273,926 B of entry payload on the large corpus rather than 2.19 GiB of content. Measured: 63.31 / 320.90 / 3,625.01 ms, i.e. 2.4–3.7× the server's own A6 encode. That transcode does produce a real, valid pack, 69,761,188 B on the large corpus, marginally smaller than the 70,028,526 B git shipped, because zlib-rs at L6 is not git's exact deflate.
So A6's true system cost — server encode + client decode + client transcode — is 4,732 ms on the large corpus against pack-copy's 25.30 ms, or 187×, for 9.2% fewer bytes. Still a bad trade, but a bad trade rather than an absurd one. The distinction is worth preserving, because it is the difference between "wrong idea" and "right idea, wrong economics".
One asymmetry in pack-copy's favour that is easy to miss. The transcoded pack is not the pack the server holds. Deflate is not deterministic across levels or backends, as P-007's closing note records. So a raw-encoded transfer also gives up bit-identical replication between server and client, which pack-copy provides for free and which gunnar mirror (§11) would like to have.
What the capability costs when nobody negotiates it
A capability nobody negotiates is not free, and gunnar's own caps.rs is built around that fact; its module doc is about advertisements drifting out of sync with parsers.
Carrying gunnar-encoding=zstd would cost, permanently, on every stock-git request:
- one more token in every
git-upload-packadvertisement, on both v2 and the v0 fallback (caps.rs:190-234), and inOffered, which is currently a one-field struct; - a second serving path in
upload_pack.rsthat is exercised by roughly none of production traffic and therefore rots (theS-011/P-002silent-serial failure mode, one layer up); zstdin the appliance dependency graph and itsccbuild step (below), whether or not any client ever negotiates;- a client-side decoder and transcoder that must be correct, because it writes
.git.
Against a benefit that is negative on bytes for A3/A4, and 9–18% of bytes for 39–379× server CPU for A6, on the fraction of requests where both ends are gunnar.
znippy-zoomies: what is usable, what it costs, and the C question
Corrected against the brief, which understated this workspace. znippy-zoomies is more than src/; it carries eight sibling crates. What follows is per-crate, because the P-006 toolchain cost belongs to exactly one of them and it is not the one people assume.
| crate | role | runtime deps | build.rs |
C / cmake / network |
gatling (published as rotaryengine 0.1.10) |
fork-join engine | anyhow, bytes, futures |
none | none, pure Rust |
linflate 0.1.10 |
raw-DEFLATE decompressor | none | none | none, pure Rust, zero deps |
znippy-zip-core |
ZIP central-dir + flush split | gatling, linflate |
none | none |
lgz |
parallel gzip/tar decode | gatling, linflate, flate2, tar, memchr, memmap2, anyhow |
none | none by default |
ljar, lzip-parallel |
parallel JAR/ZIP decode | gatling, linflate, znippy-zip-core |
none | none by default; opt-in zlib-ng feature pulls flate2/zlib-ng-compat → C + cmake |
lbzip2 |
parallel bzip2 decode | gatling, memchr, anyhow |
none | none |
zstd-sys-rs 1.5.18 |
zstd FFI | libc |
yes | cc + bindgen + vendored C, -march=native |
P-006's cost is OpenZL's, and it does not follow from using zoomies. P-006 correctly faults znippy's codec (openzl-sys-rs) for cmake-building C/C++ and fetching facebook/openzl from GitHub during cargo build and linking stdc++. Nothing in znippy-zoomies does that. Its one C crate, zstd-sys-rs, vendors its zstd sources in-tree and compiles them with cc; there is no network fetch and no cmake. That is a different class of problem, and it is also an opt-in crate nobody here needs to use.
The decisive fact about the zoomies codecs, for this file's purposes: they are decompressors, all of them. Every flate2::write::GzEncoder occurrence in the workspace — lgz/src/lib.rs:131, lgz/src/parallel.rs:236,245, lgz/src/speculative.rs:702,744 — is inside #[cfg(test)] mod tests, building fixtures. There is no production compressor anywhere in znippy-zoomies. Serving a pack needs deflate. The compressor side of any transport encoding gets nothing from that repo, and would come from flate2/zlib-rs (already linked) or zstd (a new C build step).
linflate vs zlib-rs on real git objects: MEASURED, and it loses here
Same box, same day, single core, best-of-9, correctness asserted against zlib-rs's own output on every stream before timing (mismatch = 0 across all 87,708 streams tested).
| corpus | streams | mean object | zlib-rs |
linflate |
speedup |
| gunnar, loose objects on disk | 690 | 11,585 B | 497.1 MiB/s | 374.3 MiB/s | 0.75× |
| nornir, per-object streams in a pack | 12,305 | 1,673 B | 394.6 MiB/s | 313.7 MiB/s | 0.80× |
| nornir, loose objects on disk | 191 | 23,749 B | 597.9 MiB/s | 449.6 MiB/s | 0.75× |
| hermes, per-object streams in a pack | 74,522 | 1,640 B | 483.7 MiB/s | 388.1 MiB/s | 0.80× |
| gunnar, single biggest object, 380 KB | 1 | — | 1,644.9 MiB/s | 1,357.3 MiB/s | 0.83× |
| hermes, single biggest object, 2.27 MB | 1 | — | 377.8 MiB/s | 290.1 MiB/s | 0.77× |
| hermes, one 64 MiB stream over the same content | 1 | — | 742.0 MiB/s | 645.1 MiB/s | 0.87× |
linflate is 0.75–0.87× of zlib-rs on every git-shaped input tried, and it is not a small-object artefact. The one-big-stream row, built specifically to reproduce the shape linflate's README benchmarks (Silesia, one large buffer), still gives 0.87×. Rebuilt with RUSTFLAGS="-C target-cpu=native" to rule out a crippled SIMD build, the result is unchanged (0.78× / 0.83× / 0.84×); copy.rs:85-90 resolves AVX2/AVX-512 at runtime via is_x86_feature_detected!, so the default build was never the handicap.
The README's table (linflate ~700 MB/s vs zlib-rs ~550 MB/s, Silesia) is not contradicted for Silesia; that corpus was not run. What is measured is that it does not carry over to git objects on this box, and git objects are the only input gunnar has. The likely reason is visible in the design: linflate is full-buffer with a thread-local table pool, optimised for long streams with stable Huffman tables, whereas git deflates each object independently at level 6 with a fresh table, so table construction is amortised over 1.6 KB of output in a pack, not megabytes.
This is a negative result about a house crate and it should be read as one number, not a verdict on linflate. It is a well-built decompressor that wins on the corpus it was tuned for. It is simply not the faster path for git objects, and P-007 already moved gunnar onto the one that is.
Do git's streams contain flush boundaries? MEASURED: no
The brief's second correction is right in general and does not apply here. Only a measurement settles that.
linflate::deflate_scan::find_all_flushes scans for the 4-byte 00 00 FF FF that a Z_FULL_FLUSH produces. Run over every stream in every corpus, with each candidate then validated by inflate_segment-ing from it and checking the output is a byte-exact suffix of the true decompressed data:
| corpus | streams | streams containing 00 00 FF FF |
candidates | genuine boundaries |
| gunnar loose | 690 | 0 | 0 | 0 |
| nornir pack streams | 12,305 | 0 | 0 | 0 |
| nornir loose | 191 | 0 | 0 | 0 |
| hermes pack streams | 74,522 | 6 | 6 | 0 |
| hermes, one 64 MiB stream | 1 | 1 | 1 | 0 |
Zero genuine flush boundaries in 87,709 real git streams. The six hits in the hermes pack are the coincidental in-Huffman-data matches deflate_scan.rs:17-19 explicitly warns about, and validation rejects all six. Git compresses with Z_FINISH and never emits a full flush, so deflate_scan has nothing to find in git data. The intra-stream parallel axis is not available for git objects, and the parallel axis for a pack is across objects, which is P-004's territory, where the serving tier is deliberately serial.
Where linflate would matter after P-001: barely
The brief's own position is the correct one, and the numbers support it. P-001 deletes the inflate from the common serve path entirely: a copied pack entry is never decompressed. What is left that genuinely inflates is repack(), connectivity checks on push, and policy gates that read blobs. All batch or receive-side work, none of it the clone path. Applying a 0.80× factor to a shrinking share of a shrinking cost is not worth a dependency, even a zero-dependency one. rotaryengine remains the right answer for the batch tier's parallelism (P-011), which is a separate question from which inflate runs inside each worker.
No, none of this requires forking gix, and here is the place that comes closest
The brief's position is tested below rather than assumed, and it survives. Three things were checked, and one genuine obstruction was found that is not about codecs.
1. The entry type is fully constructible. There is no codec lock-in. gix_pack::data::output::Entry (data/output/mod.rs:20-33) has four public fields:
pub struct Entry {
pub id: ObjectId,
pub kind: entry::Kind, // Base | DeltaRef | DeltaOid — all public
pub decompressed_size: usize,
pub compressed_data: Vec<u8>, // whatever bytes gunnar wants
}
Nothing is sealed, nothing is #[non_exhaustive], and Entry::to_entry_header (data/output/entry/mod.rs:166) is public. Gunnar can construct entries carrying raw, zstd-framed or any other payload without touching gix. A fork buys nothing at this layer.
2. The pack-entry seam P-001 needs already exists as a public type. gix_pack::find::Entry { pub data: Vec<u8>, pub version } (find.rs:1-11) is exactly P-001's proposed read_pack_entry return shape, and it is what output::entry::Entry::from_pack_entry (entry/mod.rs:65-131) consumes. That function copies the existing compressed bytes verbatim and re-points the OfsDelta header (:89-112), downgrading only RefDelta (:113). gix_pack::Find (find_traits.rs:14) is a plain public trait with no sealing and no private supertrait; gunnar's TieredObjectStore can implement it over packs it already owns.
3. The one place a gix internal genuinely blocks something. Named, as asked:
gix_pack::data::output::bytes::FromEntriesIterholds itsoutputfield asgix_hash::io::Write<W>, private (data/output/bytes.rs:22), constructed inFromEntriesIter::new(bytes.rs:56-77).gix_hash::io::Write::new(gix-hash-0.26.0/src/io.rs:115-125) hardcodescrate::hasher(object_hash), which issha1_checked::Sha1with UBC collision detection. There is no way to inject a different hasher.
This matters concretely: P-001's prescribed fix step 3 is "route write_pack through gix_pack::data::output … rather than hand-rolling, per LAW 5". Doing that literally would hand the trailer back to sha1dc and give up P-008's landed 4.02×, precisely in the shape (L0, no deflate) where P-008 measured 4.02× rather than 1.06×. The two entries' prescriptions collide, and nothing in either file says so.
It does not require a fork, because the route around it is short and is the shape gunnar already has. Use gix for what is hard and generic, count::objects → entry::iter_from_counts(Mode::PackCopyAndBaseObjects) to produce output::Entry values, then keep gunnar's own PackWriter (which already owns TrailerHasher, crates/gunnar-pack/src/checksum.rs) to emit the bytes. All the types crossing that boundary are public. LAW 5 is satisfied: the delta/pack-copy logic is reused, and what is kept is a 60-line byte emitter gunnar wrote for a measured reason. Recorded as P-020.
A fourth thing worth flagging while in there. iter_from_counts fans out via gix_features::parallel::in_parallel_if with a thread_limit (iter_from_counts.rs:66, 75-79), and its threshold is counts.len() > 4_000, which nearly every real clone exceeds. On the serving path that is per-request intra-request fan-out, which P-004 settled against. Whoever lands P-001 must pass thread_limit: Some(1) on the serve path and let the batch tier use the pool. This is a one-field decision, not a fork, but it is silent if missed — the same shape as P-002.
Conclusion on the fork question: no fork. No gix internal blocks a zero-copy or alternative-codec path. The single obstruction found is a hardcoded hasher, not a hardcoded codec, and it is routed around by keeping a writer gunnar already owns. Inheriting ~40 fast-moving crates to avoid one Vec<u8> copy of a pack header would be a bad trade even if the obstruction were real.
Entries
P-019 — A negotiated non-zlib transport encoding is measurably worse than pack-copy, on both axes
-
Found: 2026-08-03
-
Status: WONTFIX 2026-08-03, measured and rejected. Reopen only if
P-001is abandoned, or if the A6 economics change (see below). -
Severity: scale
-
Where: design-level.
crates/gunnar-wire/src/caps.rs:190-234(where the capability would be advertised) ·crates/gunnar-wire/src/upload_pack.rs:344-375·P-001·TOTAL-PROMPT.md§6 -
What: The proposal is a protocol-v2 capability under which a gunnar client and a gunnar server exchange objects raw (no per-object deflate) inside one zstd or lz4 stream, so that cross-object redundancy becomes visible to the compressor for the first time. The reasoning is sound and the measurement confirms the mechanism: on the mid corpus, raw+zstd-3 reaches 28.45× against per-object zlib's 3.48×.
It loses anyway, because P-001 extracts the same redundancy for free. Git's delta encoder already found it, in the pack the client pushed; pack-copy ships those deltas with a memcpy. MEASURED (large corpus, 74,523 objects, single core, oden, 2026-08-03): pack-copy 70,028,538 B / 25.30 ms; raw+zstd-3 103,168,818 B / 2,039.29 ms; raw+zstd-9 72,551,454 B / 3,940.52 ms. Pack-copy wins on bytes and on CPU against both.
The naive variant — zstd over the finished packfile — was measured too, as the version people reach for: 3.2% / 26.3% / 30.1% fewer bytes on the three corpora for +2.34 / +287 / +1,882 ms on top of the most expensive arm in the table. Not zero, as the "compressing entropy" argument predicts, because zstd finds duplication between similar deflate streams, but never worth its cost.
lz4 is dominated everywhere (2.05× / 2.86× / 2.96× on bytes) and needs no further consideration.
The one arm that beats pack-copy on bytes is pack-copy's own entries, undeflated, through one zstd stream (delta instructions preserved): 13.0% / 17.7% / 9.2% fewer bytes for 379× / 137× / 39× the server CPU, plus a client transcode measured at 63.31 / 320.90 / 3,625.01 ms, i.e. 2.4–3.7× the server's own encode. Total system cost on the large corpus, server encode + client decode + client transcode: 4,732 ms against pack-copy's 25.30 ms, or 187×, for 9.2% fewer bytes. At the load target, where serve CPU saturates before bandwidth does, that is the wrong direction. If bandwidth ever becomes the binding constraint and CPU does not, this is the arm to revisit, and only this one.
-
Why it matters at load: A capability nobody negotiates is dead weight, and stock git is the client in nearly every request a forge serves. Carrying it costs a token in every v2 and v0 advertisement, a second serving path in
upload_pack.rsthat production traffic never exercises (theP-002/S-011silent-rot shape),zstdand itsccbuild step in the appliance graph, and a client-side transcoder that writes.git. The benefit on the addressable slice is negative for the raw-object arms and 9–18% of bytes for 39–379× CPU for the best one. -
Fix: Do not build it. Land
P-001. If a bandwidth argument is made later, make it against measured egress and re-run this probe, not against the ratios in this table. -
Verify: The probe is at
/home/rickard/scratch/cargo/gunnar-transport-probe/(throwaway;P-015owns the durable harness). Every arm asserts a byte-exact round trip before it is timed, and the pack-copy row is pinned against stockgit clone, which produces packs of 1,475,754 / 7,600,737 / 70,028,558 bytes against the arm's 1,475,734 / 7,600,717 / 70,028,538, a twenty-byte difference that is exactly the unappended SHA-1 trailer. Seen RED: an arm that dropped bytes aborts on its round-trip assertion rather than reporting a flattering size. -
Closed: 2026-08-03, WONTFIX.
P-001makes it unnecessary.
P-020 — P-001's prescribed fix routes through FromEntriesIter, which silently gives back P-008's 4.02×
-
Found: 2026-08-03
-
Status: TODO
-
Severity: correctness (of the plan, not of shipped code)
-
Where:
gix-pack-0.73.0/src/data/output/bytes.rs:22, 56-77·gix-hash-0.26.0/src/io.rs:115-125·P-001's Fix step 3 ·P-008's Closed note ·crates/gunnar-pack/src/checksum.rs·crates/gunnar-pack/src/write.rs:64, 163 -
What:
P-001prescribes routingwrite_packthroughgix_pack::data::output—count::objects→entry::iter_from_counts→bytes::FromEntriesIter— citing LAW 5. The first two are right and are the whole value. The third is not.FromEntriesIterholds its sink as a privateoutput: gix_hash::io::Write<W>field, built insidenew(), andgix_hash::io::Write::newhardcodesgix_hash::hasher(object_hash), i.e.sha1_checkedwith UBC collision detection on. There is no constructor, no builder and no field access that admits a different hasher.
So adopting FromEntriesIter would revert P-008, which landed crates/gunnar-pack/src/checksum.rs::TrailerHasher precisely to keep sha1dc off the pack trailer. And it would revert it in the shape where it is worth most: P-008 measured 1.06× at L6 but 4.02× at L0. L0 is exactly the shape pack-copy produces, because the deflate that used to dominate is gone. The two entries prescribe incompatible things and neither says so.
-
Why it matters at load: After
P-001, the trailer hash is ~95% of the remaining compression+hash budget (P-008, 0.86 ms of 0.91 ms per MiB). Handing it back to sha1dc while landing pack-copy would cancel most of the second-largest win in the file, invisibly, with every test still green: a pack written with either hasher is byte- identical absent an attack, which is whattests/trailer_hash.rsasserts. No existing guard would go red. -
Fix: Use
gix_pack::data::outputfor entry production only —count::objects→entry::iter_from_counts(Mode::PackCopyAndBaseObjects), which yieldsVec<output::Entry>with all four fields public — and keep gunnar'sPackWriteras the byte emitter, feeding itoutput::Entryvalues instead of inflated object data. All types crossing that seam are public; no fork is involved. LAW 5 is satisfied: the delta-selection and pack-copy logic is reused; only a 60-line emitter that exists for a measured reason is kept.
While there, pass thread_limit: Some(1) on the serving path. iter_from_counts fans out through gix_features::parallel::in_parallel_if whenever counts.len() > 4_000 (iter_from_counts.rs:66, 75-79), which nearly every real clone exceeds. That is per-request intra-request fan-out, which P-004 settled against. The batch tier (repack, gc) should use the pool; the serve path must not.
-
Verify: Extend
crates/gunnar-pack/tests/trailer_hash.rswith a guard that fails if the pack writer's hasher isgix_hash::hasheron a SHA-1 pack. The existing tests cannot see this, because both hashers produce the same bytes. The measurable proxy isP-008's own: a pack write at level 0 must stay near 1,442 MiB/s, not fall back to ~358. Seen RED by swappingTrailerHasherforgix_hash::hasherand watching the L0 figure drop 4×. Separately, assert the serve path'sthread_limitisSome(1)in the appliance feature set;P-002's guard is the template, and it must resolve the appliance set rather than the workspace. -
Closed:
P-021 — linflate is 0.75–0.87× of zlib-rs on git objects; the zoomies workspace has no compressor at all
-
Found: 2026-08-03
-
Status: DONE 2026-08-03: measured, negative, recorded so it is not re-measured
-
Severity: smell
-
Where:
/home/rickard/git/znippy-zoomies/linflate/(0.1.10, zero runtime deps, nobuild.rs) ·linflate/src/copy.rs:85-90·linflate/src/deflate_scan.rs:17-19·linflate/README.mdbenchmark table ·P-006·P-007 -
What:
linflateis a pure-Rust raw-DEFLATE decompressor with zero dependencies and no build script, dependency-wise the cheapest possible addition and the obvious candidate to replacezlib-rson gunnar's inflate paths. MEASURED on oden 2026-08-03, single core, best-of-9, correctness asserted stream-by-stream againstzlib-rs(mismatch = 0 across 87,708 real git streams):
| corpus | mean object | zlib-rs |
linflate |
speedup |
| gunnar, 690 loose objects | 11,585 B | 497.1 MiB/s | 374.3 | 0.75× |
| nornir, 12,305 pack streams | 1,673 B | 394.6 MiB/s | 313.7 | 0.80× |
| hermes, 74,522 pack streams | 1,640 B | 483.7 MiB/s | 388.1 | 0.80× |
| hermes, one 64 MiB stream | — | 742.0 MiB/s | 645.1 | 0.87× |
Not a small-object artefact: the one-big-stream row reproduces the shape the README benchmarks (Silesia) and still gives 0.87×. Not a crippled build either: rebuilt with -C target-cpu=native the figures are unchanged (0.78×/0.83×/0.84×), and copy.rs:85-90 resolves AVX2/AVX-512 at runtime anyway. The README's Silesia numbers are not disputed; they do not carry over to git objects, plausibly because git deflates each object independently at L6, so linflate's full-buffer table setup amortises over ~1.6 KB rather than megabytes.
Two structural facts found alongside, both worth keeping.
linflateis raw-DEFLATE only. No zlib CMF/FLG parse, no adler32 verify. Git objects are zlib-wrapped, so using it means skipping 2 bytes, ignoring 4, and giving up the adler32 integrity check thatzlib-rsperforms. That last part is a correctness cost, not a six-line wrapper.- Git streams contain no
Z_FULL_FLUSHboundaries, sodeflate_scanis inert on git data. Across all corpora: 87,709 streams, 700 00 FF FFcandidates, 0 genuine independently-decodable boundaries after validating each byinflate_segmentagainst a byte-exact suffix of the true output. The intra-stream parallel axis does not exist for git objects; the axis for a pack is across objects, which isP-004's settled territory.
And the compressor gap, which is the load-bearing finding: znippy-zoomies contains no production compressor of any kind. Every GzEncoder in the workspace (lgz/src/lib.rs:131, lgz/src/parallel.rs:236,245, lgz/src/speculative.rs:702,744) is inside #[cfg(test)] mod tests. Serving a pack needs deflate; that comes from flate2/zlib-rs, already linked.
-
Why it matters at load: After
P-001a copied pack entry is never inflated, so what remains on the inflate path isrepack(), push-side connectivity checks and policy gates reading blobs. Applying a 0.80× factor to a shrinking share of a shrinking cost is not worth a dependency, even a free one. Recorded so the next agent does not re-run this measurement expecting a win. -
The
P-006correction this carries: the cmake + GitHub-fetch +libstdc++costP-006documents belongs to znippy'sopenzl-sys-rs, and not toznippy-zoomies. In that workspace:gatling/rotaryengine0.1.10 is pure Rust (anyhow,bytes,futures);linflatehas zero deps;lgz/ljar/lzip-parallel/lbzip2/znippy-zip-corehave nobuild.rsand pull no C by default, thoughljar/lzip-parallelcarry an opt-inzlib-ngfeature that would pullflate2/zlib-ng-compat, i.e. C plus cmake, and that feature must stay off. The one C crate iszstd-sys-rs, which vendors its sources and usescc; no network, no cmake. Precision matters here becauseP-006is a shippability blocker and inheriting its conclusion wholesale would wrongly rule outrotaryengine, whichP-011wants. -
Fix: None. Keep
zlib-rs(P-007). Do not addlinflate. Keeprotaryengineavailable for the batch tier's parallelism (P-011). That is a question about work distribution, not about which inflate runs inside a worker. -
Verify:
/home/rickard/scratch/cargo/gunnar-transport-probe/src/bin/inflate.rs. Both codecs run against the same in-memory streams with reused decompressor state and scratch on both sides; the linflate arm is given a per-object output slice sized from the known decompressed size, which is the best case a real caller has. Seen RED: an earlier harness allocated a 256 KiB scratch per stream inside the zlib arm and still hadzlib-rsahead at 0.85×; removing the handicap moved it to 0.75×, i.e. the correction went againstlinflate, not for it. -
Closed: 2026-08-03.
P-022 — EWAH is word-aligned so OR/AND NOT "obviously" vectorise; measured, negative, removed
-
Found: 2026-08-04
-
Status: DONE 2026-08-04: measured, negative, recorded so it is not re-measured
-
Severity: smell
-
Where:
crates/gunnar-bitmap/src/words.rs(the module docs carry the full tables) ·crates/gunnar-bitmap/src/bitmap.rs:107-138, 204-222·crates/gunnar-bitmap/examples/bench.rs·crates/gunnar-delta/src/scan.rs:26-62, whose finding this independently confirms -
What: EWAH stores literal words and run words as 64-bit words, so a literal run reaches
Bitmap::apply_ewahas a contiguous&[u64]with no shifting and no masking. That is the textbook shape for a vector unit, andORplusAND NOTis the whole of a bitmapped fetch. An AVX2 backend was written behindis_x86_feature_detected!resolved into aOnceLock— the same shapegunnar-deltauses — and A/B'd against the portable loop on the real path, where it lost.
The corpus settles it before any timing. MEASURED over the 53 real .bitmap files on oden, 2026-08-04 — 4,824 commit bitmaps, 28,001 literal runs:
literal-run length, u64 lanes |
share of runs | share of lanes |
| exactly 1 | 69.6% | 36.1% |
| 2 | 16.3% | 16.9% |
| 3..=4 | 9.0% | 15.4% |
| 5..=8 | 3.4% | 10.8% |
| 9..=32 | 1.3% | 8.7% |
| 33..=128 | 0.3% | 11.5% |
95.0% of literal runs cannot fill one AVX2 register (4 u64 lanes), and they carry 68.5% of all literal lanes. Microbenchmark, both backends in one process, best-of-9, oden idle-pinned:
| lanes | 1 | 2 | 4 | 8 | 16 | 64 | 256 | 1024 | 8192 | 65536 |
| avx2 ÷ portable | 0.58× | 0.52× | 0.59× | 0.53× | 0.87× | 1.44× | 1.30× | 1.40× | 1.14× | 1.08× |
Crossover between 16 and 64 lanes; the gain decays above 1024 as the loop goes memory-bandwidth-bound, which is uber_garm's finding about encode on a different codec and the same box.
The dense pair was the remaining hope, and it also lost. Whole bitmaps are wider than literal runs (p50 83 words, p90 566, p99 1165), so or_with/and_not_with sit in the 1.30-1.44× region. A build dispatching only those was still slower on Reachability::objects_to_send, 7 of 8 alternating pairs, taskset-pinned to one core under box load 82-122:
| pair | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| portable, µs/query | 4.107 | 4.100 | 4.147 | 4.095 | 4.372 | 3.996 | 4.026 | 4.021 |
| dense-only AVX2 | 4.237 | 4.154 | 4.197 | 4.138 | 4.192 | 4.159 | 4.096 | 4.120 |
~1.5%, seven times out of eight. Dispatching everywhere was 4/4 against at 23-52% slower. All three builds selected the identical 417,075 objects over 9,542 queries, so this is the same work three ways and not a faster build that did less.
-
Why it matters at load: The reason is
scan.rs's and it now has two independent confirmations in this repository: a#[target_feature]function cannot be inlined into a caller without the feature, while the portable loop is a bounds-check-free countedzipthat LLVM auto-vectorises after inlining it into the caller. Three dense operations per query saving ~4 ns each cannot repay a call barrier on a 4 µs query. The arithmetic never favoured it; only the intuition did. -
Fix: None to make. The AVX2 backend and the
simdfeature were removed;crates/gunnar-bitmap/src/words.rskeeps the one thing that was worth having, a singleOpdefinition of the four operations that every loop routes through, and carries the measurement so the next agent does not re-run it expecting a win.
The portable change that did stand on its own is separate and stayed: the literal arm of apply_ewah indexed with get_mut and break, which is a loop with an early exit and cannot be vectorised at all. It is now a clamped slice.
-
Verify:
crates/gunnar-bitmap/tests/words.rspins each operation's meaning against hand-written expectations rather than against a second implementation, because two implementations can agree on a wrong meaning. While the AVX2 backend existed the same file ran a differential sweep, seen RED by computingOp::Oras_mm256_and_si256: it fired at length 4 and not at 0..=3, which is exactly the boundary where a vector register first fills, and is the evidence the sweep was measuring the vector body rather than the tail. -
Closed: 2026-08-04, negative.
What could not be determined
- No end-to-end clone was measured. Every figure here is codec CPU on one core. The graph walk (
P-010) and object lookup (P-003) are excluded and remain unmeasured, and afterP-001they are what a clone costs. No claim about clone wall-time is made. - Partial fetches were not measured. The pack-copy row assumes a full clone, where every delta's base is also in the transfer. A fetch of a subset must re-base or expand some deltas; that cost is real, is bounded by the number of deltas whose bases fall outside the request, and was not measured.
RefDeltadowngrade was not isolated.from_pack_entryturns ref-deltas into base objects (entry/mod.rs:113). The corpora here arerepack -adfoutput, which is ofs-delta throughout, so this path contributed nothing and its cost is unknown.- Silesia was not run, so
linflate's published comparison is neither confirmed nor refuted on its own corpus. Only its transferability to git objects was tested. - oden is 32 threads against a 64-core target, but every number here is single-core by design, so the extrapolation caveat in
performance-design.mddoes not bind these rows. - Concurrency was not modelled. The argument that serve CPU saturates before bandwidth does is
P-004's and this file inherits it; it is not re-derived here.