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/forge-bakeoff-benchmarks.md

gunnar against gitea and Forgejo

Generated: cargo xtask mashup → .nornir/assets/mashup_forge_bakeoff.svg and the table below. Generator: xtask/src/mashup.rs. Arms: bench/examples/forge/. Recipe for the standalone shell run: .nornir/bench/forge-bakeoff.sh.

Nobody types the numbers in

Every figure on the chart and in the table below is read back out of the iceberg bench_runs table through nornir bench history-show gunnar --json. There is no constant in the generator holding a measurement, and there is no fallback: an arm with no row is printed as NOT MEASURED, with the command that would produce it, rather than filled in from a remembered value.

That rule exists because of what hand-entered numbers did to this repository's own front page. It claimed 510 tests when the real count was 989, 314 crates when the appliance build resolved 278, 45 audit entries when stinky.md had 49, and "nothing has been load-tested" while P-015 was done. Every one of those was true on the day it was typed. A literal does not go stale loudly, so the fix is not to be more careful — it is to stop having literals.

What is being compared, and what is not

One engine, drawn twice. Forgejo is a gitea fork. During a real clone podman top catches both containers exec'ing the identical /usr/bin/git-upload-pack → git pack-objects --revs --thin --stdout --delta-base-offset --include-tag. The chart therefore draws gunnar against one rival bar, and that bar is whichever of the two sent fewer bytes — the comparison is never against the weaker of the pair. gunnar's server process spawned zero children under the same probe.

The znippy cold tier did not run. What is measured is gunnar on the filesystem: pack_tier returns an empty vector, RepackOptions::cold_tier() (crates/gunnar-store/src/repack.rs:277) has zero call sites in any server crate, and the control plane prints "no znippy cold tier is built". Objects are gix-odb packs on disk, the same place gitea's are. The arm records this as a znippy_wired column rather than as a silence, and it is in the conditions row below. No figure here is evidence for or against the tier. See cold-tier-decision.md.

Bytes lead, milliseconds follow. The byte counts are what one relay in front of every server counted; the wall-clock is reported beside them and is not what any verdict is computed from. On a shared box the milliseconds move with the load and the bytes do not.

Every bar is the wire, and the wire is the harsher column. One quantity for all four operations — the bytes the relay saw travel from server to client — so the four groups are comparable and one instrument produced all of them. The obvious alternative for the transfer arms is the pack that ended up on disk, and it is deliberately not what is drawn: index-pack --fix-thin completes a thin pack by copying in bases the client already held, so a Go forge's on-disk pack is larger than what it sent, while gunnar's is the same size because gunnar sends no thin pack. Scoring the fetch on the on-disk pack therefore reports a milder loss than the wire did. Both figures are in the table below, side by side, so which one the bar is can be checked rather than trusted.

What the arms cover, and what each spread says

Moved here from the front page 2026-08-05, where it was the paragraph under the chart. Every figure keeps the conditions it was measured under.

Cost leads, bytes follow. Bytes mostly tie — both engines hand out a pack git pack-objects made, so a tie is the expected answer. What does not tie is what a request costs the box, so that is the column the headline leads with, and it carries the client count it was measured at.

The ramp runs twice. c<N> is a cgroup quota of 8 CPUs across 32; c<N>-pin is 8 dedicated ones. gunnar's cost per request climbs 2.8× under the first and is flat under the second; the Go forges are flat under both. With dedicated cores gunnar's cost per request is within 2% of gitea's at 32 clients.

fetch runs in three maintenance states and under all four --delta-order choices. On a pack already 78.8% deltified, every ordering re-encodes LARGER than copying — 1.69× at best, 3.59× at worst — so monotone gc keeps the copy.

Every row carries whether the server it was measured against had a .bitmap. A push leaves none, gc writes one, and the two are different code paths on both sides of the wire.

Read is not O(client history). repack() writes a .bitmap and select_objects reads it: 0.278× the exact walk's store touches and 0.531× the shipping walk's — oden 2026-08-04, 12 real repos, client half behind.

Beyond the bake-off. All 19 workspace_nordisk repos serve in 686 MiB peak under 8 concurrent readers and 8 writers — arm gunnar.constellation, oden 2026-08-04.

How a row is made honest

Same RAM, CPU and disk parameters on all tests. bench/examples/forge/parity.rs does not take the caps on trust: it reads each container's own cgroup back and aborts the run unless the quotas that actually applied agree across the engines. Those read-back quotas — not the ones that were asked for — are the figures in the conditions row.

Which rows get bars

Four operations across up to three corpora and three engines is two dozen bars, and two dozen bars is a table someone has drawn rectangles on. The chart draws one bar group per operation, on the corpus where that operation's finding is sharpest, and prints the corpora it did not draw as ratios beside it.

Choosing a subset is the one move that could turn an honest measurement into a dishonest picture — pick the arms gunnar wins and every bar is true and the chart is a lie. So select_drawn builds the set by construction and check_drawn refuses to render one that hides a verdict the full set produced, or that retires the worst measured ratio in favour of a milder loss. The guard is seen red four ways in xtask/src/mashup.rs's own tests.

Every measured row is in the table below, drawn or not.

A known defect in the selection, found 2026-08-05

select_drawn's first rule picks one corpus per operation by sharpness() — the largest distance from a tie over any measured axis. On fetch, gc'd there are two corpora and they are two different maintenance states:

corpus what it is
diverge-gc the default gc — the pass each engine's own cron runs
diverge-gcx the same again with --aggressive on all three

Only one of them gets drawn, and the front page labels it fetch, gc'd, so a reader takes it for the gc'd state rather than for one of two. Which one wins is not decided by anything about the fetch. Both rows have gunnar at 13 MB peak RSS against a rival 252 MB, so both score sharpness 19.384615 on RSS, and RSS is the largest distance from a tie on both — the two sharpnesses are equal to the bit. max_by returns the last of an equal run, and the rows are sorted by corpus name, so diverge-gcx is drawn because gcx sorts after gc.

The two rows do not say the same thing. On the run of 2026-08-05T01:57 the drawn one is 0.7201× on the wire (gunnar sends less) and the retired one is 0.9965× (a tie). On the previous committed run, 2026-08-04T23:42, the order was the other way: the drawn diverge-gcx was 1.0394× (gunnar sends more) and the retired diverge-gc was 0.9965×, the lower of the pair. Same rule, same tie-break, opposite direction — which is what an arbitrary choice looks like from the outside.

check_drawn does not catch either case, and is not wrong not to: 0.9965× is a tie by the 2% band, the tie verdict is covered on the wire axis by other drawn rows, and no verdict the full set produced goes undrawn. The hole is in the selection, not the guard. Fixing it by construction means one of:

Until then, read fetch, gc'd as one of two maintenance states and take the other from the table below.

Every measured row

op corpus what the wire bytes are objects wire: gunnar gitea Forgejo ratio verdict on-disk pack g/gt/fj ms g/gt/fj net CPU s g/gt/fj peak RSS MB g/gt/fj
clone large pack and protocol, server to client 36 172 6 218 847 6 223 962 6 226 272 0.9992× tie, within 2% 6 216 044 / 6 216 044 / 6 216 044 1017 / 1044 / 976 0.149 / 0.242 / 0.255 304 / 655 / 691
clone real pack and protocol, server to client 12 370 8 147 458 8 151 518 8 153 078 0.9995× tie, within 2% 8 134 052 / 8 134 041 / 8 134 041 942 / 1186 / 588 0.069 / 0.131 / 0.134 304 / 655 / 691
clone small pack and protocol, server to client 3 257 1 780 998 1 781 783 1 783 400 0.9996× tie, within 2% 1 779 249 / 1 779 249 / 1 779 249 320 / 284 / 257 0.019 / 0.039 / 0.038 304 / 655 / 691
clone xl pack and protocol, server to client 352 396 64 913 604 64 993 927 64 992 441 0.9988× tie, within 2% 64 899 986 / 64 899 986 / 64 899 986 9508 / 7302 / 5928 2.157 / 2.678 / 2.635 304 / 655 / 691
clone year pack and protocol, server to client 28 893 24 554 159 24 561 872 24 564 398 0.9997× tie, within 2% 24 514 397 / 24 514 397 / 24 514 397 1009 / 1043 / 1296 0.234 / 0.260 / 0.265 304 / 655 / 691
clone+fetch+push mix8 reads and writes at once on one repository 144 688 38 736 982 30 344 730 30 344 241 1.2766× gunnar sends more 29 068 964 / 29 063 040 / 29 063 040 587 / 678 / 682 1.297 / 2.603 / 2.968 189 / 606 / 537
clone, ramped c1 one batch of N concurrent clients 28 893 24 554 153 24 561 471 24 564 914 0.9997× tie, within 2% 24 514 397 / 24 514 397 / 24 514 397 1084 / 826 / 826 0.218 / 0.254 / 0.251 315 / 541 / 565
clone, ramped c1-pin one batch of N concurrent clients 28 893 24 554 095 24 563 331 24 567 532 0.9996× tie, within 2% 24 514 397 / 24 514 397 / 24 514 397 828 / 738 / 713 0.225 / 0.249 / 0.242 480 / 545 / 565
clone, ramped c16 one batch of N concurrent clients 462 288 392 856 675 392 816 108 392 811 771 1.0001× tie, within 2% 392 230 352 / 392 230 352 / 392 230 352 1926 / 1760 / 1366 5.030 / 3.977 / 3.958 361 / 545 / 565
clone, ramped c16-pin one batch of N concurrent clients 462 288 392 848 785 392 785 624 392 792 924 1.0002× tie, within 2% 392 230 352 / 392 230 352 / 392 230 352 1412 / 1281 / 1296 4.078 / 3.841 / 3.957 480 / 545 / 565
clone, ramped c4 one batch of N concurrent clients 115 572 98 216 205 98 240 052 98 233 409 0.9998× tie, within 2% 98 057 588 / 98 057 588 / 98 057 588 1337 / 889 / 961 0.930 / 1.010 / 1.043 315 / 541 / 565
clone, ramped c4-pin one batch of N concurrent clients 115 572 98 215 164 98 237 150 98 245 726 0.9998× tie, within 2% 98 057 588 / 98 057 588 / 98 057 588 907 / 848 / 798 0.965 / 0.969 / 1.003 480 / 545 / 565
clone/fetch, ordered gc nh1-clone one repository per delta ordering, both operations off it 14 667 1 620 698 1 415 066 1 414 942 1.1454× gunnar sends more 1 618 917 / 1 412 132 / 1 412 132 108 / 97 / 97 0.033 / 0.015 / 0.015 15 / 277 / 265
clone/fetch, ordered gc nh1-fetch one repository per delta ordering, both operations off it 2 912 344 612 331 466 331 536 1.0397× gunnar sends more 418 630 / 348 586 / 348 586 75 / 70 / 71 0.021 / 0.027 / 0.027 15 / 277 / 265
clone/fetch, ordered gc nh2-clone one repository per delta ordering, both operations off it 14 667 1 620 698 1 414 836 1 414 834 1.1455× gunnar sends more 1 618 917 / 1 412 132 / 1 412 132 108 / 97 / 97 0.034 / 0.015 / 0.015 15 / 277 / 273
clone/fetch, ordered gc nh2-fetch one repository per delta ordering, both operations off it 2 912 344 617 331 692 331 461 1.0397× gunnar sends more 418 630 / 348 586 / 348 586 76 / 71 / 71 0.021 / 0.026 / 0.027 15 / 277 / 273
clone/fetch, ordered gc pw-clone one repository per delta ordering, both operations off it 14 667 1 620 697 1 414 806 1 414 730 1.1456× gunnar sends more 1 618 917 / 1 412 132 / 1 412 132 108 / 95 / 96 0.033 / 0.015 / 0.015 16 / 277 / 274
clone/fetch, ordered gc pw-fetch one repository per delta ordering, both operations off it 2 912 344 616 331 531 331 629 1.0395× gunnar sends more 418 630 / 348 586 / 348 586 75 / 70 / 72 0.021 / 0.026 / 0.028 16 / 277 / 274
clone/fetch, ordered gc size-clone one repository per delta ordering, both operations off it 14 667 1 620 699 1 414 963 1 415 029 1.1454× gunnar sends more 1 618 917 / 1 412 132 / 1 412 132 108 / 96 / 96 0.033 / 0.015 / 0.016 16 / 272 / 260
clone/fetch, ordered gc size-fetch one repository per delta ordering, both operations off it 2 912 344 618 331 391 331 614 1.0399× gunnar sends more 418 630 / 348 586 / 348 586 75 / 72 / 71 0.021 / 0.028 / 0.028 16 / 272 / 260
fetch diverge the answer to a negotiation, server to client 2 912 344 617 296 695 296 726 1.1615× gunnar sends more 418 630 / 383 801 / 383 801 81 / 101 / 102 0.027 / 0.059 / 0.060 21 / 269 / 262
fetch, gc'd diverge-gc the same negotiation off a maintained repository 2 912 344 617 345 924 345 815 0.9965× tie, within 2% 418 630 / 418 425 / 418 425 76 / 79 / 77 0.022 / 0.029 / 0.027 13 / 256 / 252
fetch, gc'd diverge-gcx the same negotiation off a maintained repository 2 912 238 698 331 487 331 614 0.7201× gunnar sends less 328 497 / 348 586 / 348 586 78 / 70 / 70 0.023 / 0.026 / 0.026 13 / 263 / 252
ls-remote real the ref advertisement 0 7 090 7 295 7 295 0.9719× gunnar sends less — (no pack) 13 / 14 / 14 0.003 / 0.005 / 0.004 74 / 333 / 332
ls-remote small the ref advertisement 0 1 048 1 215 1 215 0.8626× gunnar sends less — (no pack) 11 / 13 / 13 0.002 / 0.005 / 0.004 74 / 333 / 332
ls-remote year the ref advertisement 0 17 918 18 144 18 151 0.9875× tie, within 2% — (no pack) 13 / 15 / 15 0.003 / 0.007 / 0.007 74 / 333 / 332
push base the server's response to 2 MB in 0 1 293 2 164 2 100 0.6157× gunnar sends less — (no pack) 187 / 831 / 1102 0.301 / 0.397 / 0.844 91 / 339 / 334

Conditions: 3 interleaved rounds · loadavg 155.23 at the end of the run · device read-ahead 2048 kB · gunnar cgroup 8.00 cpus / 8192 MB · gitea cgroup 8.00 cpus / 8192 MB · forgejo cgroup 8.00 cpus / 8192 MB · znippy cold tier not wired — gunnar on the filesystem

Source: iceberg bench_runs · oden · 32 cores · 2026-08-05T01:57:09.874732+00:00 · 3 interleaved rounds · loadavg 155.23

What the losing arm is

The fetch arm is S-003: the client deltifies, the server does not. gunnar has no server-side delta compression, so a fetch that a Go forge answers with a thin pack built out of deltas is answered by gunnar with whole objects. Pack copy recovers 100% of a clone and 97.6% of a fetch (P-009 in performance-design.md), which is why the clone arm is a tie and the fetch arm is not. It is drawn at its true length, in the losing colour, with the ratio printed next to it.