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/delta-chain-cost.md

What a delta chain costs gunnar, and where

Delta chain lane, 2026-08-04, host oden. Every figure below was taken on that host that day. Claims are tagged MEASURED (a command was run and its output is quoted) or INFERRED (read out of source without a measurement).

The question this lane was given: resolving a git delta chain — what does it cost gunnar, and on which path? Nobody had measured it on gunnar's own paths.


0. The answer, before the evidence

Chain depth costs gunnar essentially nothing, on every path it has, and the two places it was expected to be expensive are both the other way round.

path expected MEASURED
clone / fetch entry copy flat flat, and slightly cheaper deep: 0.034 µs/object at depth 0, 0.022 µs at depth 50
push verification (index_thin_pack) the one real cost 1.24× FASTER at depth 50 than at depth 1, and 1.78× faster than a pack with no deltas at all
header() fixed today ~0.1 µs whole, 0.7–1.5 µs delta, and flat in depth — a header walk is ~20 ns per link
ObjectStore::read the walk, skipped by the bitmap the only path that pays, and it pays for a missing delta base cache rather than for depth

And the reason is not subtle once the corpus is weighted by bytes rather than counted: deep chains sit on tiny objects. Across 19 real repositories, objects at depth ≥ 5 are 26.2% of objects and 0.70% of raw bytes. The largest object at depth ≥ 25 anywhere in 60 739 objects is 4 716 bytes.

The 12.4× that znippy's DeltaEntry pays at depth 50 was measured on a 256 kB entry. That is 54× larger than the largest object at depth ≥ 25 in the entire corpus, and 137× the depth-≥5 median of 36 bytes. The 12.4× is a real property of that encoder; it is not a cost gunnar's repositories can present to it.


1. Depth across 19 repositories, counted and weighted

MEASURED: git verify-pack -v over the single pack in each workspace_nordisk member's checkout at /home/rickard/git/<repo>, 60 739 objects. The parse (NF==5 whole, NF==7 delta with depth in $6) was checked against git's own chain length = N: histogram on the same command and agrees on the whole count and on every bucket — that check is what makes these numbers usable, and two lanes got all-zeros from a bad awk today without it.

meanD is over all objects. meanD_w weights each object's depth by its raw (inflated) size — what a reconstructor actually has to move.

repo whole delta maxD meanD meanD_w objs d≥5 raw bytes d≥5
nornir 3284 10147 50 7.95 0.728 42.8% 1.05%
znippy 3565 4385 50 3.35 0.097 19.6% 0.57%
facett 2940 4292 50 4.11 0.110 24.7% 0.71%
znippy-zoomies 2384 3206 49 2.83 0.095 17.5% 0.17%
holger 2431 3072 42 2.93 0.266 21.5% 1.94%
skade 1242 2216 33 2.97 0.229 21.9% 1.39%
dwarves 925 1853 43 4.83 0.119 29.6% 0.72%
knut 917 1756 50 3.23 0.309 22.4% 1.33%
workspace_nordisk 1342 1345 43 3.28 0.037 22.6% 0.29%
Skidbladnir 702 1246 30 3.36 0.219 23.9% 0.48%
korp 661 1233 26 4.16 0.257 32.2% 1.82%
edda 834 916 42 2.30 0.140 11.6% 0.26%
ordning 585 646 14 1.77 0.080 13.6% 0.45%
modgunn 252 522 19 2.97 0.242 23.8% 2.17%
draupnir 207 339 14 2.21 0.145 18.3% 0.36%
tunnr 339 275 11 1.03 0.082 5.9% 0.03%
korp-collectors 139 270 18 2.69 0.059 23.2% 0.39%
funnel-core 101 116 7 1.14 0.013 4.6% 0.04%
bifrost 36 18 5 0.59 0.010 1.9% 0.03%
all 19 22 886 37 853 50 4.32 0.250 26.2% 0.70%

Five of nineteen sit on git's default cap of 50. Count-weighted that reads as "chains are deep in real repositories", which is true and is what the earlier six-repo table said. Byte-weighted the same corpus has a mean depth of 0.250 — an order of magnitude below the count-weighted 4.32 — and that gap is the whole finding.

Where the bytes actually are

depth objects % objects raw bytes % raw mean raw mean packed
0 22 886 37.7% 283 147 488 88.5% 12 372 5 809
1 10 155 16.7% 20 674 169 6.5% 2 036 1 402
2 5 376 8.9% 8 261 178 2.6% 1 537 997
3 3 727 6.1% 4 924 844 1.5% 1 321 741
4 2 689 4.4% 576 925 0.2% 215 147
5–9 7 409 12.2% 1 316 988 0.4% 178 129
10–24 6 255 10.3% 750 367 0.2% 120 107
25–50 2 242 3.7% 177 875 0.1% 79 82

Size distribution, MEASURED: at depth ≥ 5 the raw size is p50 36 B, p90 145 B, p99 1 867 B, max 39 414 B. At depth 0 it is p50 649 B, p90 12 350 B, p99 102 734 B, max 17 722 289 B.

By type, the deep tail is trees: 1 758 of the 2 242 objects at depth ≥ 25 are trees, mean 53 bytes. These are the one-entry directory nodes a nested crate layout produces (crates/, crates/x/, crates/x/src/), each a near-copy of its neighbour, which is exactly what a delta chain is for.

The one number this table does not contain

The base is not small. MEASURED on nornir: the whole object at the bottom of a chain has mean raw size 51 739 B for objects at depth 8, 57 489 B at depth 16 and 69 871 B at depth 32. Reconstruction cost tracks the root's size, not the depth — see §2.

Caveat, stated because it bounds everything above. These are developer checkouts: one pack plus loose objects (gunnar's own has 565 packed objects and 6 137 loose). A forge repository is fully packed. The packed portion is the part that has chains at all, so it is the right corpus for this question, but the per-repo object counts are not the repositories' sizes.


2. Cost per path, per depth, on real chains

MEASURED with bench/examples/deltachain-probe.rs against a bare repo holding nornir's own pack copied byte-for-byte (never git clone --bare, which silently repacks). Objects are bucketed by exact depth; each timed sweep reads every object in the bucket once on a handle whose cache starts empty. taskset -c 2, load average 6.8–8.8.

depth n mean raw mean root read header entry copy
0 256 1 072 1 072 6.35 µs 0.11 µs 0.034 µs
1 256 105 920 8.24 µs 1.50 µs 0.023 µs
2 256 600 37 157 62.6 µs 1.22 µs 0.027 µs
4 256 338 64 294 91.1 µs 1.02 µs 0.024 µs
8 256 212 65 508 96.6 µs 0.75 µs 0.024 µs
16 175 157 57 490 94.5 µs 0.69 µs 0.023 µs
32 79 121 69 871 141.7 µs 0.99 µs 0.022 µs
50 17 56 11 940 39.6 µs 1.18 µs 0.023 µs

Read the read column against mean root, not against depth: depth 50 (root 11.9 kB) costs 39.6 µs while depth 2 (root 37 kB) costs 62.6 µs and depth 32 (root 70 kB) costs 141.7 µs. Cost is ~1.5–2 µs per kilobyte of chain root plus ~0.25–0.35 µs per link. Fifty links is ~15 µs; one 64 kB root is ~95 µs.

The one exception on the copy path

write_pack_copy_thin has a veto at crates/gunnar-pack/src/copy.rs:441-462: a DeltaOid entry whose base the client cannot vouch for is written whole, which means a full try_find_cached(..., cache::Never) — one chain resolution per vetoed object. INFERRED, not measured: a clone passes client_has = None, so allow_thin_pack is false and gix emits no DeltaOid at all; the veto count is therefore zero on clone and bounded by the fetch's external-base count otherwise. Worth counting if a fetch ever looks slow.


3. Push verification: deeper is faster

MEASURED with bench/examples/indexdepth-probe.rs. A ladder: the same 13 431 nornir objects, packed by stock git pack-objects --delta-base-offset --window=10 at --depth=1,2,4,8,16,32,50, plus one pack of the same objects with no deltas. iter is BytesToEntriesIter in Mode::Verify — the front half of index_pack, which decompresses and CRC32s every entry and applies no deltas. resolve is the remainder: delta tree, delta application, and hashing every resolved object. Best of 5, two independent passes, taskset -c 2-5.

pack bytes vs d50 index iter resolve resolve%
depth 1 69 469 788 1.823× 0.945 s 0.453 s 0.492 s 52.1%
depth 2 56 484 769 1.482× 1.012 s 0.368 s 0.644 s 63.6%
depth 4 46 015 660 1.208× 0.875 s 0.292 s 0.583 s 66.6%
depth 8 40 832 256 1.071× 0.883 s 0.249 s 0.633 s 71.8%
depth 16 38 910 887 1.021× 0.812 s 0.234 s 0.579 s 71.2%
depth 32 38 321 162 1.006× 0.756 s 0.230 s 0.526 s 69.6%
depth 50 38 108 046 1.000× 0.766 s 0.227 s 0.539 s 70.4%
no deltas 133 934 858 3.515× 1.368 s 0.895 s 0.473 s 34.6%

Second pass agrees within 2–12%; the ordering does not change.

The resolve column is the answer. It is 0.47–0.64 s across the entire ladder including the pack with zero deltas in it. A phase that costs the same whether there are 10 147 deltas at depth 50 or none at all is not delta application; it is the SHA-1 of 351 MiB of resolved object data, which every pack pays identically. Delta application is buried inside that and is not separable from it at this resolution — which is itself the finding.

The depth-dependent term lives in iter, and it runs the helpful way: 0.895 s with no deltas down to 0.227 s at depth 50, 3.9×, because a deeper chain is fewer bytes to inflate and CRC. Net, index_pack of depth 50 is 1.24× faster than depth 1 and 1.78× faster than no deltas.

This is gix's inverse traversal doing what git's does: the delta tree is walked from bases outward, so each delta is applied exactly once onto a parent already in memory. Depth is O(1) amortised there, not O(depth). gunnar's strongest arm is not at risk from chain depth, and a repacker that shortened chains to protect it would slow push verification down while making the wire 1.82× larger.

MEASURED on the same day, one real push through the real ReceivePack (3 362 objects, bench/examples/pushstage-probe.rs, reusing gunnar_bench::stages::push): pack_ingest 93.6% of push wall clock, connectivity 6.3%, everything else 0.2%. That is consistent with the 74.6–90.9% already on record.


4. The gix-pack cache gap: real, measured, and on the wrong path to matter much

The research lane INFERRED from source that gix-pack issues exactly one cache.put against git's every-intermediate insertion. The situation in gunnar was worse than that and nobody had looked: gix-odb's new_pack_cache defaults to None (gix-odb-0.83.0/src/cache.rs:102) and its try_find then passes gix_pack::cache::Never (:228). crates/gunnar-store/src/odb.rs never called set_pack_cache, so every ObjectStore::read through the fs backend re-resolved its chain from the root, with no cache of any kind.

MEASURED, bench/examples/deltachain-probe.rs, whole pack read once per object on a cold handle:

corpus objects pack order, no cache 64 MiB LRU ratio random order ratio
nornir 13 431 0.958 s 0.284 s 3.38× 1.99×
facett 7 232 0.174 s 0.085 s 2.04× 1.62×
knut 2 673 0.052 s 0.019 s 2.68× 1.91×

Narrowest form of the same statement — one 51-link chain, base to tip, once each: 6.4× / 7.6× / 7.4×. That is the O(depth²) git's packfile.c:1715-1719 exists to remove.

Cache size sweep, nornir, pack order:

cache wall vs none
none 0.963 s 1.00×
256 KiB 0.588 s 1.64×
1 MiB 0.405 s 2.38×
4 MiB 0.352 s 2.74×
16 MiB 0.281 s 3.43×
64 MiB 0.285 s 3.37×

What it is worth end to end, which is the part that matters

Clone and fetch answer from the .bitmap and copy pack entries verbatim. Neither touches this path at all. The one hot path that does is receive-pack's connectivity walk. MEASURED, interleaved A/B on two builds of the same binary differing only in the cache cap, one real 3 362-object push, three passes each:

no cache 4 MiB cache ratio
connectivity 13.73 ms 10.50 ms 1.31×
whole push (min of 5, ×3 passes) 219.3 / 219.4 / 220.2 ms 216.1 / 216.2 / 216.4 ms 1.016×

Non-overlapping across passes, so 1.6% is real — and it is 1.6%. A correct fix worth about a percent and a half of a push today.

Why it was still worth landing, and why it did not cost a crate

gix-pack's MemoryCappedHashmap is behind pack-cache-lru-dynamic, which pulls clru → hashbrown, allocator-api2, equivalent, foldhash. MEASURED with cargo xtask guard: the appliance graph goes 280 → 283 crates (267 → 270 third-party) and S:gunnar-store 103 → 107. That is a bad trade for a bounded map against a §2 guard the project treats as law.

crates/gunnar-store/src/delta_cache.rs is std-only, FIFO rather than LRU, and has the admission control git lacks (git adds an entry's size before evicting, packfile.c:1303-1311, so an over-sized entry is admitted and then evicts everything else). Guard after: 280 crates / 267 third-party, 7 tracked, 0 violations — the baseline exactly.

FIFO against gix-pack's real LRU at the same 4 MiB cap, whole-pack sweep: nornir 0.350 s LRU / 0.340 s FIFO, facett 0.085 / 0.085, knut 0.022 / 0.022 — 0.995× to 1.027×. On a walk, recency and insertion order coincide, and the LRU's bookkeeping costs slightly more than the extra hits it buys.


5. Whether gunnar gc's re-encoding changes depths

MEASURED, same host and day, on copies of the same three corpora.

The default gc changes nothing on any of the three. All three are 59–76% deltified, far above DeltaPolicy::Auto's 0.20 threshold (repack.rs:273-279), so the re-encode declines outright. --aggressive runs the full search on all three (1.0–16.9 s) and then discards it on knut and nornir because the recoded pack came out larger than the copy — the size guard at repack.rs:1010, and the already-consolidated guard at :1123.

Where it does land (facett, --aggressive, default name-hash-v2 order), it makes chains deeper: mean depth over deltas 6.92 → 9.51, objects at d≥5 1 786 → 2 150 (+20%), pack 16 135 329 → 15 940 403 B (−1.2%). Against stock git repack -adfk --depth=50 on the same object set, gunnar's re-encode runs +34% to +81% mean depth and +14% to +92% more objects at d≥5.

Byte-weighted, that is a much smaller thing than it sounds. Raw bytes at depth ≥ 5:

corpus baseline pack git repack -adfk --depth=50 gunnar --aggressive v2
nornir 1.05% 0.66% 1.12%
facett 0.71% 0.30% 0.67%
knut 1.33% 0.79% 1.29%

So gc's extra depth lands on the same small objects everything else does, and §§2–3 say the paths that would pay for it either do not resolve chains or get faster when chains get deeper. The trade is fine. Size-wise gunnar's encoder is at rough parity with git's full -f repack, beating it by 1–3% on all three, and size ordering is 1.7–1.9× worse than name-hash-v2 everywhere.

Two things this turned up that are not this lane's to fix:


6. What went red

A blind wrapper still in the tree, not mine to fix

bench/src/readside.rs's Counting ObjectStore does not forward bitmap(), so it inherits the trait default of None and gunnar.fetch_readside measures the walk while production takes the bitmap. This is the same defect the decomp lane fixed in bench/src/stages.rs on origin/main (1074100); the readside copy was not part of that commit. Flagged rather than edited — three lanes are in that file.


7. What I could not determine