gunnar bench/ — the benchmark & defect-finding suite
This crate measures gunnar's git server: how fast each operation is, how it scales with repository size, and — for the two storage engines — which one wins where and why. It is as much a defect finder as a scoreboard: several arms exist to catch a wrong answer (a torn object graph, a lost ref) that a timing number would hide.
bench/ links no storage engine directly. It knows a path, not an engine: an arm points GUNNAR_BENCH_GUNNAR_SERVER (or ENGINE_SERVER) at a gunnar serve binary built with a chosen engine, and drives it over git's real ext:: / ssh transport. That is what lets one binary be benchmarked as znippy (the shipped default) and gix (the A/B engine, never shipped) from the same run — the engine is chosen per pass by GUNNAR_ENGINE.
The batteries
| battery | what it answers | kind |
vs_git_* arms |
end-to-end git commands (clone/fetch/push/ls-remote) vs stock git, per engine | scoreboard |
opsprobe mode |
each storage-trait op timed in isolation, per engine | scoreboard |
two-engine matrix (engine-matrix.sh, engine-ops-probe.sh) |
znippy vs gix, every op & command × sizes × reps | scoreboard |
two-engine.sh |
the original per-engine suite runner | scoreboard |
fetkaos (Fet Kaos Monkey) |
100 000 seeded-random pack operations against a disposable container; red probes corruptpack/dropref/dropobject/killmid |
defect finder |
opstorm |
whole-surface seeded storm — git + control-plane + replication + abdication interleaved; red probes killmid/corruptclone/deafcontrol/freezeaccepts; external-target mode drives a live appliance |
defect finder |
*-probe.rs arms |
single-dimension probes (advertise, deltachain, indexdepth, oidlookup, pushstage, readside, stage, stomach) | scoreboard |
Every timed op asserts its result is correct (a clone must fsck clean, a get must return the right bytes) — a bench that times a silently-failing op is measuring an error path.
The engine surface
Every git command lowers to a finite set of storage-trait methods (git-storage-trait::{GitOps, GitServe}). That is the whole seam — there is nothing below it. The benches and the complexity analysis cover it in full:
GitOps put · put_pack · put_refs · put_refs_cas · get · has · size ·
extents · refs · update_ref · reachable · gc
GitServe read · header · sizes · head · set_head · emit_pack · select (+ helpers)
Operations outside the engine — client-side commands (merge/rebase/diff/status), the control plane (repo/user/policy/replication), and LFS — are covered by sitetest, opstorm, and the gunnar-lfs tests, not by this engine suite.
Result 1 — algorithmic complexity (from code)
Variables: n objects, r refs, P packs (gix only), k batch, d delta depth, C commits, |W|/|H| want/have closure, s object size.
The recurring gap: znippy folds all objects into one S-tree (static search tree — znippy-zoomies/src/stree.rs, ~40× faster than binary search) so every point lookup is O(log n). gix probes packs serially, so every lookup carries an O(P) pack-count factor. That single ×P is the mechanism behind gix's slower log / blame / cat-file / fetch.
| op | znippy | gix | note |
get |
O(log n)+O(s) |
O(P·log n)+O(d) |
S-tree vs per-pack fanout |
has |
O(log n), free miss |
O(P·log n)+statx |
znippy skips the tail read on a miss |
size |
O(log n) (column) |
O(P·log n)+O(d) |
znippy has a size column |
header |
O(log n) (column) |
O(P·log n)+O(d) |
znippy answers type+size from a column |
extents |
O(k·log n) |
O(k·P·log n) |
|
refs |
O(r) |
O(r·log r) |
fold-clone vs walk+sort |
update_ref |
O(log r) ✱ |
O(log r) |
✱ was O(r), see optimizations |
put_refs_cas |
O(r)+O(e·log n) |
O(r)+O(e·lock) |
one namespace snapshot per atomic batch |
head |
O(log r) ✱ |
O(log r) |
✱ was O(r) |
put_pack |
O(bytes) + deferred |
synchronous O(objs·resolve) |
znippy acks now, resolves async |
reachable |
O(1) map ✱ + bitmap OR |
bitmap OR `O( | W |
gc |
O(n) compact |
O(n) mark + O(P) sweep |
Command compositions (per engine): clone = reachable + k·get + refs; fetch = reachable(have/want) + closure gets; push = put_pack (+ ref CAS); ls-remote = refs; log/blame/cat-file = m·(get + header).
Full analysis: O-ANALYSIS.md.
Result 2 — measured, znippy vs gix
the desktop, 2026-09-01, 256 / 2048 / 16384 commits, 5 reps + warmup, both engines. engine-gix builds and serves clean. The box was not idle during the run (load-1 mean 13.4), so absolute times are provisional — but per-cell spreads are tight and non-overlapping between engines, so the ratios hold; only the absolutes would shift on a quiet box.
End-to-end commands
| dimension | winner | gap @ 16384 commits |
| clone (serve wall) | znippy | 7.23 s vs 17.7 s → 2.4×, and it widens with size |
| push (receive-pack) | gix | 0.80 s vs 1.54 s → 1.9× |
| RSS floor | gix | 13.6 vs 29.2 MB → 2.15× lighter |
| pack fidelity | tie | byte-identical, recompress 0 both engines |
fetch_divergent |
gix fails | gix trips its own correctness guard (the pack-copy path does not engage on incremental fetch — byte-correct but recompresses); znippy passes |
Per-op isolation (opsprobe)
| op | winner | gap |
reachable @16384c |
znippy | 5.6 ms vs 14.9 s → ~2700× (gix has no reachability bitmap, walks the whole graph) |
put_refs_cas (batch) |
znippy | 30–500× |
refs |
znippy | 30–50× |
size / extents / read |
znippy | 1.3–3× |
header |
gix | ~5× |
update_ref (single) |
gix | ~2× |
has |
gix | 1.3–1.7× |
One line: znippy is the faster reader / server (clone, reachability, refs); gix is the faster writer (push ingest) and has a lighter footprint.
Full tables + raw rows: ENGINE-MATRIX.md, runs/*/results.jsonl.
Result 3 — optimizations this suite drove (shipped znippy)
The analysis + matrix agreed on where znippy loses; both were fixed in znippy (plugins/native/znippy-plugin-git/), each proven byte-identical to the old path and across a fold-changing push before landing:
reachablememoized. It rebuilt twoO(C)maps (by_commit,graph) on every negotiation; now folded once beside the bitmaps/graph and invalidated by the same token ascommit_raw. Map-buildO(C) → O(1): the root-tip query goes 1.258 ms → 0.73 µs at C = 20 000 (~1720×) and is now flat across a 417× range of C. (The full-closure walk staysO(C)— that is the bitmap-OR floor, not the maps.)head/ single-refupdate_refkeyed. They cloned the wholeBTreeMap<String,RefState>(O(r)) to read one row; a newRefLog::get(name)makes themO(r) → O(log r)— closing exactly theheader/update_ref/hascells where gix was ahead.
Running it
# build one server carrying both engines (znippy default, gix behind a feature)
cargo build --release --example git-store-serve --features engine-gix
cargo build --release --example nornir-bench
cd bench
SERVER=<target>/release/examples/git-store-serve
NB=<target>/release/examples/nornir-bench
# two-engine command matrix: reps, arm prefix, sizes
ENGINE_SERVER=$SERVER BENCH_NB=$NB ./engine-matrix.sh 5 "gunnar.vs_git_" "256 2048 16384"
# per-op isolation, both engines
ENGINE_SERVER=$SERVER ./engine-ops-probe.sh 5
# a single arm, one engine
GUNNAR_ENGINE=znippy GUNNAR_BENCH_GUNNAR_SERVER=$SERVER $NB # or =gix
NORNIR_BENCH_ONLY=gunnar.opstorm GUNNAR_OPSTORM_CLI=<gunnar-solo> $NB
Run on an idle box for publishable absolutes. A number taken under load is provisional; the arms record load-1 per run so a dirty row is labelled, not silently trusted. Add a 65536 grade (or GUNNAR_BENCH_H2H_BLOB_KIB=64 for a wide/deep-delta shape) for the large end.
Honesty notes
- Complexity marked inferred from structure: the S-tree inner constant, the redb tail B-tree, roaring-bitmap container costs, gix's
.idxinner fanout. The×Pouter factor and the synchronous-vs-deferred push contrast are traced from call sites. |W|/|H|closure sizes are repo-topology-dependent and left symbolic.- Every ratio above is dated and was taken under a recorded load; re-run idle before quoting an absolute.