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

Deltifying for the transfer, and the gc that was undoing it

MEASURED on oden, 2026-08-04. Subject gunnar built from this branch, /home/rickard/scratch/cargo/gunnar-xfer/release/gunnar, 26 379 264 B, mtime 22:07:17Z. Client is stock git 2.53.0. Corpus is the 19 real workspace_nordisk repositories, 18 sha1 and one sha256 (tunnr), pushed into a gunnar store by stock git — never clone --bared, which silently repacks. Every byte below is counted by one relay (cargo xtask wireproxy) in front of one server, server→client direction, and every relay row was ok.

The four scripts that produced every number here are in .nornir/bench/: transfer-setup.sh builds the corpus, transfer-arm.sh runs one arm end to end, transfer-ceiling.sh produces stock git's two reference packs and transfer-basedist.py classifies where a fetch's delta bases live. Each arm prints its subject binary's path, size and mtime before it runs anything.


1. The finding, which is a regression and not an optimisation

gunnar gc roughly doubles a shallow fetch.

store clone fetch, 50 behind fetch, 200 behind
as pushed, never repacked 118 648 990 6 843 048 42 287 796
after gunnar gc 111 046 982 13 235 040 (1.934×) 51 747 876 (1.224×)
after gunnar gc --aggressive 92 671 518 12 053 912 (1.762×) 42 103 400

19 clones, 16 fetches at depth 50, 12 at depth 200 (the rest have shorter histories). The default gc is not even re-encoding — DeltaPolicy::Auto declined on all 19, the delta fractions being 41.0%–72.3% against its 20% threshold — so this is consolidation alone doing it.

It is not that gc loses deltas. korp's delta fraction moves 67.3% → 66.0%. It is that gc turns them round.

1a. And on the bake-off fixtures it is a different answer, said by the log

"gunnar gc --aggressive produced no smaller pack on the bake-off corpus" has three possible causes and they are not distinguishable from the output size. With store.repack.delta_policy in place, the fixtures answer for themselves — small.git and large.git, pushed into a fresh store, RUST_LOG=gunnar=info:

large small
gc — policy=Auto{0.2} delta_fraction=0.7458, recompute=false 0.5135, recompute=false
gc --aggressive — policy=Recompute recompute=true, search ran over 12 240 objects recompute=true, 4 136 objects
… and then reencode_lost_to_copy: recoded 8 242 060 vs copy 7 605 155 (1.084× worse) recoded 131 679 432 vs copy 117 002 112 (1.125× worse)
pack after, all three orderings 7 605 155 — unchanged 117 002 112 — unchanged

Both candidate causes are real, on different flags. The default declined because the fixtures sit far above the 20% threshold; --aggressive ran and its ordering was genuinely worse than the one already in the pack, by 8.4% and 12.5%, and the monotone rule kept the original. transfer-v2 loses there too and leaves the same pack.

That is the opposite of what the same flag does to the 19-repository corpus above, where it wins on all 19 at 0.8105×, and the difference is entirely the input: the bake-off fixtures are git repack -adfq'd single packs, so gunnar's search is being asked to beat git's own best effort with nothing left to consolidate. The 19-repository corpus is push residue — three packs per repository, with the thin bases index-pack --fix-thin appended sitting in it twice. A repacker measured only on already-optimally-repacked fixtures is being measured on the one input where it has nothing to do.

2. The mechanism

For each repository and each cut, take the objects a fetch would send and ask where each one's delta base lives. korp, 50 commits behind, 294 objects sent:

store sent objects stored as deltas base inside the sent set base on the client's side
as pushed 207 159 47
after gunnar gc 159 159 0
after gc --aggressive --delta-order transfer-v2 218 178 40

Every delta whose base sat behind the negotiation cut was destroyed: the object became a base and now travels whole. The same shape on nornir (144 → 28), holger (138 → 59) and facett (144 → 77).

The cause is the sort key. gunnar-delta's window sorted (kind, name_hash desc, size desc, id) — git's type_size_sort — and a base must be an object earlier in that order. Size descending is blind to time, so whether the newest revision of a file ends up as the base or as a delta is decided by whether it happens to be the larger one. git can afford that because a git server re-runs pack-objects --thin over the transfer set at serve time. gunnar's serve path copies pack entries verbatim (P-001): the direction the repacker chose is the direction on the wire, for every fetch, until the next gc.

3. Why "make the fetch self-contained" is the wrong fix

The obvious reading of "deltify for the transfer" is to choose bases inside the object set a fetch sends, so no thin pack and no voucher are needed. That direction is refuted by construction. Three packs over the identical object set, all produced by stock git from the source repository, summed over the corpus:

pack depth 50 depth 200
pack-objects --thin — bases older, on the client 6 467 525 37 317 890
pack-objects — self-contained, bases inside the set 13 337 022 43 534 767

2.06× at depth 50. A fetch's object set contains at most one or two revisions of each changed file; there is nothing inside it worth deltifying against. The good base is always the previous revision, which is exactly the object the client already has.

The census's "client_has refuses 75.8% of gix's thin offers" is therefore not a reason to stop offering thin deltas. It is a reason to widen the voucher — which is another lane, on the read side — and, here on the write side, to stop the repacker destroying the offers before they are ever made.

There is also nothing for a nearer base to buy. Across ten repositories and two depths, not one sent object had a base outside both the sent set and the client's history (absent = 0), and none had a base more than ~200 commits behind the cut. The bases were never far away; they were pointing the wrong way.

For the same reason a --depth/--window knob does not reach this. A wider window searches further and a deeper chain links further, and neither can turn a chain round.

4. DeltaOrder::TransferV2

One term in the sort key: (kind, name_hash desc, **epoch ascending**, size desc, id), where epoch is a dense rank over the repository's commits, larger being newer. Inside a group of like-named objects the oldest revision now sorts first, and since a base must be earlier in the order, a base is always at least as old as its target.

The epoch comes from the walk that already computes the name hash (gunnar-store/src/pathwalk.rs), which is first-path-wins; descending the root trees in ascending committer time therefore stamps every object with the oldest commit that contains it, at the cost of one commit decode and one sort of the root trees. epoch == 0 is "no information" and reduces the key to exactly what it was, which is why every other ordering is untouched — and is asserted, not assumed, by only_the_transfer_order_dates_anything.

5. What it is worth, and what it costs

gc --aggressive --delta-order transfer-v2 against gc --aggressive (the default name-hash-v2), same store, same clients, same relay:

--aggressive --aggressive transfer-v2 ratio
fetch, 50 behind (16 repos) 12 053 912 6 516 220 0.541×
fetch, 200 behind (12 repos) 42 103 400 38 206 332 0.907×
clone (19 repos) 92 671 518 98 298 326 1.061×
stored pack 94 463 864 100 193 560 1.061×
gc wall clock 23.67 s 24.03 s 1.015×

--delta-order path-walk is in the same table for the ordering question the brief asked, and it answers it: clone 93 042 058, fetch@50 12 052 684, fetch@200 42 559 356 — statistically the same fetch as name-hash-v2's 12 053 912 and 42 103 400. Path-walk changes which objects share a window; it does not change which way the chain inside that window points, and the direction is the whole of this.

All 16 repositories improve at depth 50 against name-hash-v2; the per-repo range is 0.303× (korp) to 0.850× (ordning).

Against the never-repacked store the aggregate also improves — 0.952× at depth 50 and 0.904× at depth 200 — but not on every repository, and that has to be said rather than averaged away. Eight of the sixteen are still slightly worse than never running gc at all: Skidbladnir 1.014×, korp-collectors 1.010×, knut 1.014×, dwarves 1.037×, facett 1.044×, modgunn 1.101×, and nornir 1.480×. The aggregate is carried by korp (0.524× against the un-repacked store) and workspace_nordisk (0.601×). What nornir does that the others do not was not determined; it is the largest member and the only one with a substantial LFS-pointer population, and the arm was not re-run to isolate it.

Against stock git's own thin pack of the identical object set, the transfer ordering lands at 1.0075× at depth 50 and 1.0238× at depth 200. There is essentially nothing left on the table.

The clone and the archive are 6.1% worse, and that is the whole of the trade. Dropping size-descending inside a group is a real loss to whole-history compression. In absolute bytes on this corpus one clone costs +296 148 B and one 50-commit fetch saves +346 105 B, so the ordering pays from roughly one fetch per clone onwards — which is why it is a flag and not the default. A repository that is mostly cloned should not use it.

Three repositories (bifrost, draupnir, funnel-core — all under 50 commits) declined: the re-encode lost to the copy and the monotone rule kept the copy, which is that rule working.

There is a third cost, and it is the one nobody would look for. Over the whole corpus, 65 157 objects, from git verify-pack -v:

ordering deltas mean chain depth max
name-hash-v2 43 377 7.27 50
transfer-v2 43 807 9.84 50

More deltas and 1.35× deeper chains for a 6.1% larger pack: ordering by time finds a base for more objects, and each base is worth less. A reader pays that depth per object reconstructed. gunnar's serve path does not — it copies pack entries (P-001) and never applies a delta — so the bill lands on cat-file, on index-pack at the receiving client, and nowhere on the fetch this ordering exists to make cheap.

6. The suite, and the guard

cargo test --workspace --features gunnar-store/fs --no-fail-fast --release: 1111 passed, 0 failed, 6 ignored, 145 binaries, cargo's own exit status 0 (not a pipeline's). That is the 1107 baseline plus this change's four new tests. cargo xtask guard: dependency guard PASS, 7 tracked deviations, 0 violations; pack-pipeline guard PASS, 0 undeclared call sites; 280 crates on the appliance graph. cargo clippy --workspace --all-targets adds no warning this change did not already find in the tree (bitmap/write.rs, fanout.rs, tests/repack.rs, all unchanged here).

Correctness across the five arms: 95 clones and 140 negotiated fetches, each git fsck --full --strict and each object set compared against the source repository's own rev-list --objects. Zero failures. Every client genuinely lacked the bases — it was cloned when the server held only the old cut — so a thin delta naming a base the receiver did not have would have failed there. Both columns were seen red: a corrupted pack makes the fsck column read FAIL, and a revision the clone does not hold makes the two digests differ.

7. What was seen red

8. Two instrument faults found on the way

9. It is idempotent, and it is deterministic

Two independent transfer-v2 repacks of the same store produce byte-identical packs. A second transfer-v2 gc over an already-transfer-v2 store produces exactly the same total, 98 022 952 B — so a forge that runs this weekly does not walk its packs anywhere.

10. Which build measured what

The no gc / gc / gc --aggressive arms ran on the pre-change binary (26 370 384 B, 21:47:16Z); transfer-v2 on 26 379 264 B, 22:07:17Z; path-walk on 26 381 208 B, 22:18:53Z. That the plumbing added between them is a no-op for the existing orderings is asserted, not assumed: gc --aggressive run on the post-change binary produces a pack of exactly the same size for all 19 repositories as the pre-change arm recorded, and two independent transfer-v2 repacks of the same store produce byte-identical packs.

Every arm starts from the identical store — the four pack-before.tsv files have one md5 between them — and from identical client copies.

11. What this does not answer