Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

bench/README.md

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:

  1. reachable memoized. It rebuilt two O(C) maps (by_commit, graph) on every negotiation; now folded once beside the bitmaps/graph and invalidated by the same token as commit_raw. Map-build O(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 stays O(C) — that is the bitmap-OR floor, not the maps.)
  2. head / single-ref update_ref keyed. They cloned the whole BTreeMap<String,RefState> (O(r)) to read one row; a new RefLog::get(name) makes them O(r) → O(log r) — closing exactly the header/update_ref/has cells 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