nordisk/gunnar
bench/examples/nornir-bench.rs
//! gunnar's nornir-bench suite: the benchmark harness this repository did not
//! have, built to the spec that came out of the profiling review.
//!
//! One `BenchRun` JSON line on stdout, one `impl Bencher` per arm, ids named
//! `gunnar.<scenario>`. Metric suffixes are the type system the gate reads:
//! `_mbs` / `_per_sec` / `ops_sec` are higher-is-better, `_ms` / `_us` / `p50` /
//! `p99` are lower-is-better, `_x` / `_pct` are neutral.
//!
//! # Every arm carries its red
//!
//! A benchmark that only produces a number cannot tell *slow* from *broken*. So
//! each arm below documents (and, where it can be checked in-process, **asserts**)
//! the applied-output condition that means the thing is broken. Several arms fail
//! outright rather than reporting a fast, meaningless number:
//!
//! | arm | the assertion that makes a fake impossible |
//! |---|---|
//! | `push_large` | `parallel_speedup_x`: identical serial and parallel index throughput means the thread limit did nothing (`gix-pack`'s `parallel` feature is not declared, so the build silently takes the serial path) |
//! | `walk_1m` | commit and tree reads must be ≥ their counts (a stub cannot read a million objects) **and** `commit_reads_per_commit` must be ≤ 1 (the closure walk is the serial bottleneck; it was 2.000 until 2026-08-03) |
//! | `lookup_1m` | hit and miss measured **separately**; a suite that only measures hits cannot see that object lookup is O(packs) on a miss |
//! | `negotiate_divergent` | `rounds` must be > 1, or the fixture is not divergent and the arm is measuring the happy path |
//! | `constellation` | the server must be the **same live process** at the end, checked by `/proc` start time — an OOM otherwise reads as a client fault; and a corpus smaller than the whole membership has to be asked for by name |
//! | `import_export` | the judge is a stock-git mirror clone of the import, not the importer's store: `fast-import` leaves objects no ref reaches, 705 of 5 267 on the largest member |
//!
//! # Isolation
//!
//! Neither container nor VM, by design. The house isolation strategy is
//! same-machine comparison: `machine` is mandatory in the envelope and the gate
//! filters history to the same machine. The runner executes arms **sequentially on
//! the main thread**, so a VM would add variance and remove none. The one thing
//! that genuinely wants a container, a pinned stock `git` for interop, is not
//! built here, and that leaves stock-git interop unmeasured.
//!
//! # Fixtures
//!
//! Generated lazily, deterministically (a fixed LCG seed, so the same fixture is
//! the same bytes on any machine) and cached under `$GUNNAR_BENCH_FIXTURES`
//! (default `~/scratch/gunnar-bench-fixtures`, i.e. md1). Never
//! `/tmp`: that is RAM on this host.
//!
//! `NORNIR_BENCH_PREVIEW=1` shrinks every workload for the development loop. The
//! harness stamps such a run onto `<host>-preview`, so a preview number can never
//! land in or overwrite the real host's row.
/// The counting `#[global_allocator]`, behind `--features count-alloc`.
///
/// # Why it is a feature and not simply installed
///
/// A `#[global_allocator]` belongs to the whole executable, so switching one on
/// here puts a branch in front of every allocation in **every** arm — concurrent
/// runs' included, whose rows land in the same `bench_runs` table. Off, the
/// `#[cfg]` compiles away and this binary is byte-for-byte the one it was.
///
/// On, `gunnar.stage_*` gains its `alloc_mib_*` and `peak_pct_*` columns:
///
/// ```text
/// NORNIR_BENCH_FEATURES=count-alloc nornir bench run gunnar
/// ```
///
/// which is still `nornir bench run` and still persists. `gunnar_bench::stages`
/// detects at runtime whether this is linked and every stage arm emits
/// `alloc_measured` accordingly, so an absent allocator reads as "not measured"
/// rather than as a repository that allocates nothing.
#[cfg(feature = "count-alloc")]
#[global_allocator]
static COUNTING_ALLOC: gunnar_bench::stages::CountingAlloc = gunnar_bench::stages::CountingAlloc;
use std::io::{BufRead, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use anyhow::{bail, ensure, Context, Result};
use gix_hash::{Kind, ObjectId};
use gix_object::Data;
use gunnar_bench::packbuild::PackBuilder;
use gunnar_pack::{index_thin_pack, IndexOptions, IndexedPack, WriteOptions};
use gunnar_store::git_storage_trait::{
Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas, RefRow,
RefUpdate, Stored, TxId,
};
// One in-memory store, not two. `MemObjectStore` + `MemRefStore` were the
// `ObjectStore`/`RefStore` pair; `MemGitServe` is a single store that answers
// both halves of the contract, and a clone of it shares the same state.
use gunnar_store::mem::MemGitServe;
use gunnar_wire::receive_pack::AllowAll;
use gunnar_wire::{FetchRequest, ProtocolVersion, ReceivePack, RefStatus, UploadPack};
use nornir_bench::api::Bencher;
use nornir_bench::BenchResult;
use serde_json::{json, Map, Value};
/// The `gunnar.vs_forge_*` arms: gunnar against gitea and against Forgejo, all
/// three over smart HTTP, each in its own container booted through draupnir.
///
/// A submodule rather than another ten thousand lines in this file, and an
/// explicit `#[path]` because `mod` inside an example resolves relative to the
/// examples directory in a way that is easy to misread. `inventory` collects at
/// link time, so its `register_bench!` arms land in this binary's arm set
/// exactly as if they were written below.
#[path = "forge/mod.rs"]
mod forge;
/// `gunnar.fetkaos_monkey`: three fat repositories through a disposable gunnar
/// container, 100 000 seeded-random operations, and the container's own log
/// held to a list of lines it MUST say.
///
/// A submodule for the same reasons `forge` is one, and with the same explicit
/// `#[path]`.
#[path = "fetkaos/mod.rs"]
mod fetkaos;
/// `gunnar.multiuser_scaling`: the 102-user roster of assignment 1 driving a
/// documented op mix over token-authenticated smart HTTP against a pinned
/// real-repository corpus, asking one question — do operations scale with
/// user count. A submodule for the same reasons `forge` is one.
#[path = "multiuser/mod.rs"]
mod multiuser;
/// `gunnar.opstorm`: the whole-surface Operation Storm — a seeded interleave of
/// git transport AND the gRPC control plane AND single-node replication/
/// abdication against one running `gunnar serve`, a sibling to `fetkaos` (which
/// storms only the pack graph). A submodule for the same reasons `forge` is one,
/// and it reuses fetkaos's chaos harness (`output_within`/`OpOutcome`/`git_env`,
/// made `pub(super)` there) rather than twinning it (one implementation, not two).
#[path = "opstorm/mod.rs"]
mod opstorm;
// ───────────────────────────────────────────────────────────────────────────
// the one place that speaks both pack vocabularies
// ───────────────────────────────────────────────────────────────────────────
/// `gunnar_wire::IndexedPack`, short enough to sit in a closure signature next
/// to the `gunnar_pack::IndexedPack` this file already imports unqualified.
///
/// Both names are in scope here and they are **different types**; the alias
/// exists so the difference stays visible at every use rather than turning into
/// a one-character `IndexedPack` that reads like the other one.
type WireIndexedPack = gunnar_wire::IndexedPack;
/// Restate `gunnar-pack`'s index receipt as the wire vocabulary's.
///
/// `gunnar-wire` grew its own [`gunnar_wire::IndexedPack`] when the pack
/// vocabulary moved off `gix-pack` — one `pub struct` field spelled
/// `gix_pack::data::Version` was enough to link the whole pack pipeline into
/// builds whose point is that it is absent (see `gunnar-wire/src/pack.rs`).
///
/// The bench still indexes through `gunnar-pack` **on purpose**: it is the
/// outside oracle that disagrees when gunnar is wrong, and swapping it for the
/// engine under test would be marking your own homework. So the two shapes meet
/// here, in the single function that names both, rather than at each
/// [`PackIngest`](gunnar_wire::receive_pack::PackIngest) closure.
fn wire_indexed(indexed: IndexedPack) -> WireIndexedPack {
gunnar_wire::IndexedPack {
num_objects: indexed.num_objects,
pack_id: indexed.pack_id,
index_id: indexed.index_id,
pack_version: match indexed.pack_version {
gix_pack::data::Version::V3 => gunnar_wire::PackVersion::V3,
// `V2` and anything a later `gix-pack` adds. The wire enum is two
// variants wide by the same argument its own `from_u32` makes.
_ => gunnar_wire::PackVersion::V2,
},
hash_kind: indexed.hash_kind,
index_path: indexed.index_path,
data_path: indexed.data_path,
keep_path: indexed.keep_path,
// `None`: `gunnar-pack`'s indexer writes a real `.pack`, so the file IS
// the count and `measured_pack_bytes` falls through to stat'ing it.
// Filling this in from the receipt would report the same number twice
// and hide which path produced it.
stored_bytes: None,
}
}
/// A `gunnar-pack` indexing failure as the error the ingest contract returns.
///
/// `gunnar_wire::Error` has no `From<gunnar_pack::Error>` and should not grow
/// one: that impl is exactly the dependency edge the vocabulary move removed.
/// The oracle's failure is a storage-side failure from the protocol's point of
/// view, and `{:#}` keeps the source chain that says which part of the pack was
/// bad.
fn wire_ingest_err(e: gunnar_pack::Error) -> gunnar_wire::Error {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
"indexing the pushed pack failed: {e:#}"
)))
}
/// The same shape for an ingest that never went near `gunnar-pack`: the engine
/// took the bytes itself. One spelling for both, so a caller cannot tell which
/// arm failed by the shape of the error (one implementation, not two).
fn wire_store_err(what: &str) -> gunnar_wire::Error {
gunnar_wire::Error::Store(gunnar_store::Error::backend(what.to_string()))
}
// ───────────────────────────────────────────────────────────────────────────
// main: the filter, and the guard that makes a zero-arm run RED
// ───────────────────────────────────────────────────────────────────────────
/// The prefix every arm in this file carries. Also the filter `main()` installs.
const GUNNAR: &str = "gunnar.";
fn main() -> Result<()> {
// ── the head-to-head re-execs, before anything else ────────────────────
//
// The `vs_git_*` arms compare gunnar against stock `git` by making git's own
// `ext::` transport run a server on the far end of a pipe. That server is
// THIS binary, re-executed. Three roles, dispatched here because they must
// never touch the bench runner:
//
// * `GUNNAR_BENCH_SERVE=<side>` is the meter. Spawns the real server as a
// child, pumps the pipe both ways counting bytes, and reaps it with
// `wait4` for CPU seconds and peak RSS. Identical for both sides, so the
// measurement apparatus cancels out of every ratio.
// * `GUNNAR_BENCH_SERVE_INNER` **was** gunnar's server itself: `gunnar-wire`
// over stdin/stdout, this binary re-executed, serving out of a store it
// constructed in-process. **Gone 2026-08-10.** That in-process store was
// the thing that made the seam comment below false: `bench/` claimed to
// know "a path, not a crate" while linking the old znippy engine crate and, through
// it, four znippy crates at cargo RESOLVE time. The role is kept only to
// refuse, by name, so that a driver still setting the variable is told
// what to set instead rather than serving nothing.
// * `GUNNAR_BENCH_H2H_DRIVER` runs the whole scenario INSIDE the pinned
// container, so both sides see the same `git` and the same kernel view.
// `gunnar.fetkaos_monkey` stands in for `ssh` while it exports, so it can
// judge the RAW bytes gunnar served rather than git's rewrite of them.
// Dispatched here, before the runner, exactly like the h2h server roles.
{
let argv: Vec<String> = std::env::args().collect();
if let Some(at) = argv.iter().position(|a| a == "--fetkaos-ssh-tee") {
return fetkaos::ssh_tee(&argv[at + 1..]);
}
}
if let Some(side) = std::env::var_os("GUNNAR_BENCH_SERVE") {
return h2h_serve_meter(&side.to_string_lossy());
}
if std::env::var_os("GUNNAR_BENCH_SERVE_INNER").is_some() {
return h2h_serve_gunnar();
}
if let Some(socket) = std::env::var_os("GUNNAR_BENCH_SERVE_DAEMON") {
return h2h_serve_daemon(Path::new(&socket));
}
// The scale suite's server: the same shape, plus receive-pack and a live
// count of sessions alive at once. See `scale_serve_daemon`.
if let Some(base) = std::env::var_os("GUNNAR_BENCH_SCALE_DAEMON") {
return scale_serve_daemon(Path::new(&base));
}
if let Some(scenario) = std::env::var_os("GUNNAR_BENCH_H2H_DRIVER") {
return h2h_drive(&scenario.to_string_lossy());
}
// `GUNNAR_BENCH_H2H_CORPUS=<repo>` runs [`h2h_assert_production_corpus`] and
// nothing else, so the guard that decides whether a head-to-head arm may run
// can be **seen going red** without a container, a fixture build or an hour.
//
// It exists because that guard's whole subject is a file's absence, and a
// guard about an absence is the easiest kind to write blind. Point it at a
// `git clone --bare` carve and at the same directory after `git repack -adq`
// and it must disagree with itself; if it does not, it is not measuring
// anything (measured, not asserted).
if let Some(repo) = std::env::var_os("GUNNAR_BENCH_H2H_CORPUS") {
let repo = PathBuf::from(&repo);
let shape = h2h_assert_production_corpus(&repo, "server", "corpus-check")?;
println!(
"server_packs={} server_bitmaps={} server_pack_mb={} server_loose={}",
shape.packs,
shape.bitmaps,
mib(shape.pack_bytes / 1024.0),
shape.loose
);
return Ok(());
}
// `gunnar.fetch_readside`'s workload: serve exactly one fetch and print the
// tally. A child process rather than an in-process call, because the number
// that matters is instructions retired and `perf stat` counts a process.
if let Some(repo) = std::env::var_os("GUNNAR_BENCH_READSIDE") {
println!(
"{}",
gunnar_bench::readside::probe(
Path::new(&repo),
"refs/heads/yesterday",
"refs/heads/main",
)?
);
return Ok(());
}
// `NORNIR_MACHINE` is what the whole same-machine comparison strategy keys
// on: the gate filters bench history to one machine, and an empty machine is
// rejected downstream. Default it to the host so a bare `cargo run` still
// produces an attributable row.
if std::env::var_os("NORNIR_MACHINE").is_none() {
let host = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_default();
if !host.is_empty() {
// SAFETY: single-threaded, before any bench arm has started.
unsafe { std::env::set_var("NORNIR_MACHINE", host) };
}
}
// THE DAY-ONE TRAP. `inventory` collects registrations at LINK time,
// so a bench binary runs every `register_bench!` in everything it links, not
// just its own repo's. This happened for real on 2026-07-22: `nornir bench run
// skade` executed 213 `nornir.mimir.*` arms and reached ZERO `skade.*` arms.
//
// This crate links `nornir-bench` (the leaf harness) rather than `nornir` (the
// orchestrator, which registers its own arms), so today the linked set IS
// gunnar's set. That is a property of the current dependency graph, not a
// guarantee: one future dependency reintroduces the hazard silently. So the
// filter is set unconditionally.
if std::env::var_os("NORNIR_BENCH_ONLY").is_none() {
// SAFETY: single-threaded, before any bench arm has started.
unsafe { std::env::set_var("NORNIR_BENCH_ONLY", GUNNAR) };
}
// ── THE GUARD ──────────────────────────────────────────────────────────
// An empty run is RED, never a silently green zero-arm success (suites that
// self-skip and report green). `run_main_json` has its
// own bail for "the filter matched nothing", but it only fires when something
// WAS linked and got filtered away; a binary that linked no gunnar arms at all
// would sail through it, print a results-free envelope, and exit 0, which is
// exactly the shape of a clean pass.
//
// Proven RED, not assumed: `NORNIR_BENCH_ONLY=gunnar.nonexistent.` exits
// non-zero with this message.
let only: Vec<String> = std::env::var("NORNIR_BENCH_ONLY")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let linked = nornir_bench::api::registered_bench_ids();
let selected: Vec<&String> = linked
.iter()
.filter(|id| only.is_empty() || only.iter().any(|p| id.starts_with(p.as_str())))
.collect();
let gunnar_arms = selected.iter().filter(|id| id.starts_with(GUNNAR)).count();
if gunnar_arms == 0 {
bail!(
"RAGNARÖK: this run would execute {} arm(s) and NOT ONE of them is a `{GUNNAR}` arm. \
A bench run that measures zero gunnar arms is RED, never a green zero-arm success \
(the link-time day-one trap). NORNIR_BENCH_ONLY={:?}; linked arms: [{}]",
selected.len(),
only.join(","),
linked.join(", ")
);
}
// ── THE OTHER GUARD: WHICH TREE ────────────────────────────────────────
//
// `nornir bench run --config <p>` resolves the repository by NAME out of the
// config's grandparent, so the tree it builds and benches is not necessarily
// the tree the operator is standing in. It has taken the wrong one at least
// three times; the performance record notes run `4b65a303`, where
// "`nornir bench run gunnar` resolved the repository by name and built
// `~/scratch/wt/gunnar`, not this branch."
//
// `CARGO_MANIFEST_DIR` is baked in at compile time and therefore names the
// tree this binary really came from, whatever the runner believes. Printed
// on every run, because a run that says which tree it measured cannot make
// that mistake silently; and refused outright when the caller states an
// expectation, because a run scoped to one branch's arms wants an abort, not
// a footnote.
//
// The row carries it too, as `gunnar.capabilities.bench_tree_id`.
let tree = bench_tree();
eprintln!(
"bench: source tree {} (id {})",
tree.display(),
bench_tree_id()
);
if let Ok(expected) = std::env::var("GUNNAR_BENCH_EXPECT_TREE") {
let expected = PathBuf::from(&expected);
let same = std::fs::canonicalize(&expected).ok() == std::fs::canonicalize(tree).ok();
ensure!(
same,
"GUNNAR_BENCH_EXPECT_TREE={} but this binary was built from {}. A bench binary \
carries its source tree in `CARGO_MANIFEST_DIR`, and `nornir bench run --config` \
picks the repository by NAME out of the config's grandparent — so a run in one \
worktree can measure another, and every column of it is a true measurement of \
the wrong branch.",
expected.display(),
tree.display()
);
}
eprintln!(
"bench: {gunnar_arms} gunnar arm(s) selected out of {} linked{}",
linked.len(),
if preview() {
" (PREVIEW: shrunk workload, machine stamped <host>-preview)"
} else {
""
}
);
nornir_bench::api::run_main_json()
}
// ───────────────────────────────────────────────────────────────────────────
// shared helpers
// ───────────────────────────────────────────────────────────────────────────
/// The repository's hash kind. Decision D11 is sha256, so that is what the suite
/// measures. Benchmarking sha1 would be benchmarking a configuration gunnar does
/// not create.
///
/// Spelled out rather than read from `gunnar_store::DEFAULT_HASH_KIND`, which
/// went with the packfile engine on 2026-08-10. The surviving constant of that
/// name is `gunnar_grpc::seam::DEFAULT_HASH_KIND`, and it is a *different type*
/// (the gRPC seam's own `HashKind` enum) belonging to a different layer —
/// reaching for it because the name matches would tie the hash this suite
/// measures to a wire protocol's default. D11 is the authority, so D11 is what
/// is written here.
const HASH: Kind = Kind::Sha256;
fn preview() -> bool {
nornir_bench::api::preview_run()
}
/// Pick the full-size value, or the shrunk one under `NORNIR_BENCH_PREVIEW=1`.
fn scaled(full: usize, light: usize) -> usize {
if preview() {
light
} else {
full
}
}
fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
/// Where generated fixtures are cached. **md1**, never `/tmp`. `/tmp` is RAM on
/// the desktop, and a 1 GiB fixture there is a memory leak with a filename.
fn fixture_root() -> PathBuf {
PathBuf::from(std::env::var("GUNNAR_BENCH_FIXTURES").unwrap_or_else(|_| {
gunnar_bench::expand_home("~/scratch/gunnar-bench-fixtures")
.to_string_lossy()
.into_owned()
}))
}
/// A deterministic byte source.
///
/// Deterministic so a fixture is the same bytes on every machine and every run;
/// otherwise `ratio_x` drifts with the weather and the deflate guard is worthless.
/// Semi-compressible on purpose: pure random deflates to 1.0× and pure zeros to
/// ~1000×, and neither says anything about a git object corpus.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Lcg(seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1))
}
fn next_u64(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
self.0
}
/// `len` bytes of synthetic source text: lines drawn at random from a fixed
/// pool, which is what makes it compress the way code compresses.
///
/// Getting this right took two wrong answers, and both were wrong in a way
/// that mattered rather than in a way that was merely untidy. `ratio_x` is
/// the deflate-backend guard, and a guard on an unrepresentative corpus reports
/// on the generator instead of on the codec:
///
/// * **runs of up to 64 identical bytes → `ratio_x` 13.2×.** Not a git object
/// corpus; a compressibility that flatters every codec equally.
/// * **runs of 1–2 bytes over a 66-symbol alphabet → `ratio_x` 1.35×.** That
/// is the *entropy bound*: Huffman coding of ~6-bit symbols and no LZ
/// matches at all. deflate's match finder, which is the half a backend swap
/// actually changes, was doing nothing.
///
/// Repeated lines give the match finder real work, at a ratio in the range
/// source trees actually sit in.
fn text(&mut self, len: usize) -> Vec<u8> {
let pool = line_pool();
let mut out = Vec::with_capacity(len + 128);
while out.len() < len {
// HIGH bits. An LCG modulo 2^64 has period 2^k in its low k bits, so
// `next_u64() % 128` is a *permutation cycle*: every line appears exactly
// once per 128 draws and the text never repeats a line inside one object.
// That is how a pool-of-lines generator managed to deflate at the
// entropy bound (1.33×) while looking, in the source, like it obviously
// would not.
let line = &pool[((self.next_u64() >> 33) % pool.len() as u64) as usize];
out.extend_from_slice(line);
}
out.truncate(len);
out
}
}
/// The fixed line pool every synthetic blob is assembled from.
///
/// Built once, deterministically, from its own LCG, so the corpus is identical
/// on every machine and every run, and `ratio_x` is comparable across hosts.
/// 128 lines of 16–80 bytes puts a 16 KiB object at **~3× deflate**, which is
/// where a real source tree sits. Measured, not guessed: 256 lines gives 2.2×,
/// 64 gives 5.3× and 16 gives 13×, and the corpus has to sit somewhere a codec
/// swap can move `ratio_x` in either direction.
fn line_pool() -> &'static [Vec<u8>] {
static POOL: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
POOL.get_or_init(|| {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = Lcg::new(0x11_4E_5);
(0..128)
.map(|_| {
let len = 16 + (rng.next_u64() % 64) as usize;
let mut line = Vec::with_capacity(len + 1);
for _ in 0..len {
line.push(ALPHABET[(rng.next_u64() % ALPHABET.len() as u64) as usize]);
}
line.push(b'\n');
line
})
.collect()
})
}
/// A `Write` that counts and discards. Used where the measurement is the
/// producer's throughput, not the consumer's.
#[derive(Default)]
struct Sink(u64);
impl Write for Sink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 += buf.len() as u64;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// An [`ObjectStore`] that reports what was actually pulled through it, by kind.
///
/// This is the applied-output instrument. A wall clock and a byte count are
/// **compatible with a fake**: a server that streamed a pre-made pack from disk
/// would produce a plausible `clone_ms` and a plausible `bytes_out` while reading
/// nothing. Counting the reads that reached the object store cannot be faked by
/// anything except doing the work.
///
/// Kinds are counted separately and deliberately not folded together. During an
/// `upload-pack` the graph walk reads **commits and trees only**. Blobs are
/// discovered from tree entries and never inflated by the walker
/// (`gunnar-wire/src/graph.rs`, `tree_closure`), so a blob read can only have
/// come from the pack writer inflating it in order to re-deflate it. That is what
/// makes `objects_recompressed` mean what the pack-copy path needs it to mean.
struct Counting<S> {
inner: S,
blobs: AtomicU64,
commits: AtomicU64,
trees: AtomicU64,
other: AtomicU64,
headers: AtomicU64,
contains: AtomicU64,
}
/// The bound is `Deref`, not `GitServe`, and deliberately.
///
/// Neither `GitOps` nor `GitServe` has a blanket impl for `&T`, `Box<T>` or
/// `Arc<T>` (unlike `RepoStore`, which has one for `Arc`). The call sites here
/// hold all three shapes — `&MemGitServe` for the in-memory fixtures, and
/// `Arc<dyn GitServe>` out of `RepoStore::git()` for the on-disk ones — so
/// requiring `S: GitServe` would force a clone at some sites and be impossible
/// at others. Deref covers every shape at once and costs nothing at runtime.
impl<S> Counting<S>
where
S: std::ops::Deref,
S::Target: GitServe,
{
fn new(inner: S) -> Self {
Counting {
inner,
blobs: AtomicU64::new(0),
commits: AtomicU64::new(0),
trees: AtomicU64::new(0),
other: AtomicU64::new(0),
headers: AtomicU64::new(0),
contains: AtomicU64::new(0),
}
}
fn commit_reads(&self) -> u64 {
self.commits.load(Ordering::Relaxed)
}
fn tree_reads(&self) -> u64 {
self.trees.load(Ordering::Relaxed)
}
fn reads(&self) -> u64 {
self.blobs.load(Ordering::Relaxed)
+ self.commits.load(Ordering::Relaxed)
+ self.trees.load(Ordering::Relaxed)
+ self.other.load(Ordering::Relaxed)
}
fn touches(&self) -> u64 {
self.reads() + self.headers.load(Ordering::Relaxed) + self.contains.load(Ordering::Relaxed)
}
}
/// The twelve, forwarded; only [`GitOps::has`] is counted, into `contains`.
///
/// `contains` keeps its name and its meaning. `ObjectStore` had two membership
/// methods and this decorator counted BOTH into this one counter, precisely so
/// that `touches()` would not move when the negotiation switched between them;
/// [`GitOps`] has one, so the counter now receives from one place instead of
/// two and every total it feeds is unchanged.
impl<S> GitOps for Counting<S>
where
S: std::ops::Deref + Send + Sync,
S::Target: GitServe,
{
fn has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
self.contains.fetch_add(1, Ordering::Relaxed);
self.inner.has(oid)
}
fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> anyhow::Result<TxId> {
self.inner.put(pack, refs)
}
fn put_pack(&self, bytes: &[u8]) -> anyhow::Result<TxId> {
self.inner.put_pack(bytes)
}
fn put_refs(&self, updates: &[RefUpdate]) -> anyhow::Result<TxId> {
self.inner.put_refs(updates)
}
fn get(&self, oid: Oid<'_>) -> anyhow::Result<Option<Stored>> {
self.inner.get(oid)
}
fn size(&self, oid: Oid<'_>) -> anyhow::Result<Option<u64>> {
self.inner.size(oid)
}
fn extents(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<Extent>>> {
self.inner.extents(oids)
}
fn refs(&self) -> anyhow::Result<Vec<RefRow>> {
self.inner.refs()
}
fn update_ref(
&self,
name: &str,
old: Option<Oid<'_>>,
new: Option<Oid<'_>>,
) -> anyhow::Result<TxId> {
self.inner.update_ref(name, old, new)
}
fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> anyhow::Result<TxId> {
self.inner.put_refs_cas(edits)
}
fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Vec<Vec<u8>>> {
self.inner.reachable(want, have)
}
fn gc(&self) -> anyhow::Result<GcReport> {
self.inner.gc()
}
}
impl<S> GitServe for Counting<S>
where
S: std::ops::Deref + Send + Sync,
S::Target: GitServe,
{
fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
let found = self.inner.read(oid)?;
if let Some((kind, _)) = &found {
match kind {
ObjType::Blob => &self.blobs,
ObjType::Commit => &self.commits,
ObjType::Tree => &self.trees,
// `Tag`, and the two delta codes an inflated read must never
// return. Folded into `other` exactly as `Tag` alone used to be:
// the three counters this arm publishes are blobs, commits and
// trees, and pack-copy's `objects_recompressed` is the blob one.
_ => &self.other,
}
.fetch_add(1, Ordering::Relaxed);
}
Ok(found)
}
fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
self.headers.fetch_add(1, Ordering::Relaxed);
self.inner.header(oid)
}
/// Charged once per id, not once per call — `sizes` is the bulk `header`,
/// and charging it one would let work moved into it read as work removed.
fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
self.headers.fetch_add(oids.len() as u64, Ordering::Relaxed);
self.inner.sizes(oids)
}
fn head(&self) -> anyhow::Result<Option<RefRow>> {
self.inner.head()
}
fn set_head(&self, target: &str) -> anyhow::Result<TxId> {
self.inner.set_head(target)
}
fn emit_pack(
&self,
objects: &[Oid<'_>],
have: &[Oid<'_>],
caps: &Caps,
out: &mut dyn std::io::Write,
) -> anyhow::Result<PackStats> {
self.inner.emit_pack(objects, have, caps, out)
}
fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> anyhow::Result<Option<ReachSet>> {
self.inner.select(want, have)
}
}
/// ── RED SWITCH: `GUNNAR_BENCH_NO_PACK_COPY=1` ───────────────────────────────
///
/// The switch was `gunnar_store::NoPackCopy`, a shim that sat in front of an
/// `ObjectStore` and refused to hand the serve path a verbatim pack, so the
/// writer had to re-deflate every object. It moved into `gunnar-store` on
/// 2026-08-03 (one implementation, shared with `gunnar-wire/tests/pack_copy.rs`) and it was
/// **deleted with the packfile engine on 2026-08-10**.
///
/// There is no replacement, and that is a property of the new contract rather
/// than an omission: [`GitServe::emit_pack`] *is* the engine's pack writer, so
/// there is no longer a seam between "the store" and "the thing that decides
/// whether to copy" at which a shim could stand.
///
/// So every path that reads this now **refuses** when it is set, rather than
/// running unswitched under a switched label — the same answer
/// `crates/gunnar-server/examples/git-store-serve.rs` gives in its `main()`.
///
/// Whether this process has been asked to serve with pack-copy switched off.
fn pack_copy_off() -> bool {
std::env::var_os("GUNNAR_BENCH_NO_PACK_COPY").is_some()
}
/// One ref, by name, out of the contract's row stream.
///
/// There is no by-name ref read on [`GitOps`]: `refs()` returns every row and the
/// caller picks. That replaces `RefStore::read(&FullName)`, and with it the
/// `FullName` newtype — the contract takes ref names as `&str` and validates
/// them at the engine, so a `full_name()` helper that panicked on a bad name is
/// no longer the thing standing between a typo and a wrong answer.
///
/// `Ok(None)` distinguishes *"no such ref"* from *"a symbolic ref"*: a row with
/// no `oid` is a symref (`HEAD`); the contract settled that `refs()` does not carry
/// `HEAD` at all. Every caller here asserts on a branch tip, so a symref answer
/// would be a bug and is reported rather than unwrapped.
fn read_ref(git: &dyn GitOps, name: &str) -> Result<Option<ObjectId>> {
let Some(row) = git.refs()?.into_iter().find(|r| r.name == name) else {
return Ok(None);
};
match row.oid {
None => bail!(
"{name} resolved to a symbolic ref pointing at {:?}, not to an object",
row.symref_target
),
Some(raw) => Ok(Some(
ObjectId::try_from(raw.as_slice())
.with_context(|| format!("{name} holds an unreadable object id"))?,
)),
}
}
/// Local bridge from gunnar's object trait to gix's pack traits.
///
/// The old gunnar-store adapter went with the gix engine; the bench still needs
/// this seam for pack writers and thin-pack indexing over surviving stores.
#[derive(Clone, Copy)]
struct GunnarObjects<S>(S);
/// `anyhow::Error` as a `std::error::Error`, so it can be raised into gix's
/// exception (gix 0.88's `gix_error::Exn`) with its cause chain intact.
#[derive(Debug)]
struct AnyhowCause(anyhow::Error);
impl std::fmt::Display for AnyhowCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl std::error::Error for AnyhowCause {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.0.source()
}
}
fn to_gix_find_error(err: anyhow::Error) -> gix::Error {
// `anyhow::Error` is not `std::error::Error`, so it cannot be raised straight
// into gix's error slot the way `gunnar_store::Error` could. The newtype above
// keeps the whole cause chain, which is the part worth keeping: these errors
// surface as "a pack writer could not find an object", and the engine's
// reason for that is the useful half. (gix 0.88 replaced `find::Error`, a
// boxed `dyn Error`, with `gix_error::Exn`, 2026-10-02.)
gix::Exn::new(AnyhowCause(err)).erased().into_error()
}
impl<S> gix_object::Exists for GunnarObjects<S>
where
S: std::ops::Deref,
S::Target: GitServe,
{
fn exists(&self, id: &gix_hash::oid) -> bool {
// `GitOps::has` is fallible where `ObjectStore::contains` was not, and
// `gix_object::Exists` is not — so an error has to collapse to a bool
// here. It collapses to `false`, which is the safe direction for every
// caller of this trait in this file: a pack writer that believes an
// object is absent writes it out whole (bigger pack, still correct),
// where one that believes an absent object is present emits a delta
// against nothing (a corrupt pack).
self.0.has(id.as_bytes()).unwrap_or(false)
}
}
impl<S> gix_object::Find for GunnarObjects<S>
where
S: std::ops::Deref,
S::Target: GitServe,
{
fn try_find<'a>(
&self,
id: &gix_hash::oid,
buffer: &'a mut Vec<u8>,
) -> gix::Result<Option<Data<'a>>> {
// `GitServe::read` returns owned bytes; `gix_object::Find` hands the
// caller a `Data<'a>` borrowed from `buffer`. So the bytes are moved
// into the caller's buffer rather than copied a second time.
match self.0.read(id.as_bytes()).map_err(to_gix_find_error)? {
None => Ok(None),
Some((kind, bytes)) => {
let kind = match kind {
ObjType::Commit => gix_object::Kind::Commit,
ObjType::Tree => gix_object::Kind::Tree,
ObjType::Blob => gix_object::Kind::Blob,
ObjType::Tag => gix_object::Kind::Tag,
// An inflated read contracts to return a real object. A
// delta code here is an engine bug, and it is reported as
// one rather than guessed at.
other => {
return Err(to_gix_find_error(anyhow::anyhow!(
"GitServe::read returned pack-encoding type {other:?} for {id}, \
which is not an object type"
)))
}
};
buffer.clear();
buffer.extend_from_slice(&bytes);
// `Data::new(data, kind, hash_kind)`. The hash kind comes off the
// id that was asked for, which is the only authority in scope and
// is by construction the repository's.
Ok(Some(Data::new(buffer, kind, id.kind())))
}
}
}
}
/// Historical gix-store threshold kept only for surviving multi-pack fixtures;
/// the explicit writer arm that used the store API was retired with that engine.
const MULTI_PACK_INDEX_THRESHOLD: usize = 16;
fn mbs(bytes: u64, secs: f64) -> f64 {
if secs <= 0.0 {
0.0
} else {
(bytes as f64 / (1024.0 * 1024.0)) / secs
}
}
fn round(v: f64, places: u32) -> f64 {
let f = 10f64.powi(places as i32);
(v * f).round() / f
}
// ── the synthetic repository ───────────────────────────────────────────────
/// One generated object: its kind, its bytes and the id gunnar gave it.
struct Corpus {
ids: Vec<ObjectId>,
blobs: usize,
trees: usize,
commits: usize,
/// Total inflated content bytes across every object.
content_bytes: u64,
tip: ObjectId,
}
/// Build a real git history into `store`: `commits` commits, each adding
/// `blobs_per_commit` fresh blobs under one tree.
///
/// A real history rather than a bag of blobs, because half the suite walks it:
/// `walk_1m`, `clone_large` and `negotiate_divergent` are all questions about the
/// graph, and a graph with no shape answers none of them.
///
/// # It writes ONE PACK, because the contract has no single-object write
///
/// This used to call `ObjectStore::write(kind, bytes)` once per object. [`GitOps`]
/// has no such method — a store that keeps pushed packs verbatim refuses a
/// single-object write, and `bench/src/packbuild.rs` exists for exactly this
/// reason. So the objects are built in memory, sealed into one packfile by
/// [`PackBuilder`] and landed with a single [`GitOps::put_pack`].
///
/// The ids are unchanged: [`PackBuilder::add`] hashes through
/// `gix_object::compute_hash`, which is the same function `gunnar-store` hashes
/// with, so a fixture that names an object by id and a store that indexes it
/// still agree by construction.
///
/// **The objects land packed rather than loose, and that is closer to real.**
/// It also means the arms that used to see a loose-object store now see a
/// one-pack store; no arm in this file asserts on looseness, but it is recorded
/// because it is the kind of fixture change that moves a number quietly.
fn build_history(
store: &dyn GitOps,
hash: Kind,
seed: u64,
commits: usize,
blobs_per_commit: usize,
blob_bytes: usize,
parents_from: Option<ObjectId>,
) -> Result<Corpus> {
let mut pack = PackBuilder::new(hash);
let mut rng = Lcg::new(seed);
let mut ids = Vec::with_capacity(commits * (blobs_per_commit + 2));
let mut head = parents_from;
let mut content_bytes = 0u64;
let (mut n_blobs, mut n_trees, mut n_commits) = (0usize, 0usize, 0usize);
for c in 0..commits {
let mut entries: Vec<(String, ObjectId)> = Vec::with_capacity(blobs_per_commit);
for b in 0..blobs_per_commit {
let bytes = rng.text(blob_bytes);
content_bytes += bytes.len() as u64;
let id = pack.add(gix_object::Kind::Blob, bytes)?;
ids.push(id);
n_blobs += 1;
entries.push((format!("f{c:07}_{b:04}.txt"), id));
}
// Tree entries must be sorted by name or gix rejects the tree.
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut tree = Vec::new();
for (name, id) in &entries {
tree.extend_from_slice(b"100644 ");
tree.extend_from_slice(name.as_bytes());
tree.push(0);
tree.extend_from_slice(id.as_bytes());
}
content_bytes += tree.len() as u64;
let tree_id = pack.add(gix_object::Kind::Tree, tree)?;
ids.push(tree_id);
n_trees += 1;
let at = 1_700_000_000i64 + c as i64;
let mut text = format!("tree {}\n", tree_id.to_hex());
if let Some(p) = head {
text.push_str(&format!("parent {}\n", p.to_hex()));
}
text.push_str(&format!(
"author Gunnar Bench <bench@gunnar.invalid> {at} +0000\n\
committer Gunnar Bench <bench@gunnar.invalid> {at} +0000\n\ncommit {c}\n"
));
content_bytes += text.len() as u64;
let commit_id = pack.add(gix_object::Kind::Commit, text.as_bytes().to_vec())?;
ids.push(commit_id);
n_commits += 1;
head = Some(commit_id);
}
// One landing, after the whole graph is built. Landing per commit would put
// `commits` packs in the store and make every arm that reads this fixture a
// question about multi-pack lookup instead of the one it asks.
let bytes = pack.finish().context("sealing the fixture pack")?;
store
.put_pack(&bytes)
.context("landing the fixture pack in the store")?;
Ok(Corpus {
ids,
blobs: n_blobs,
trees: n_trees,
commits: n_commits,
content_bytes,
tip: head.context("a history with zero commits has no tip")?,
})
}
/// A flat corpus of loose blobs, no graph, for the arms that are about the
/// *store*'s addressing rather than the *walker*'s traversal.
///
/// # Every blob is distinct, and that is a correction (2026-08-03)
///
/// It was not. [`Lcg::text`] assembles an object out of whole lines drawn from a
/// 128-line pool, and a 96-byte object is two or three of them, so the corpus
/// could only ever contain a few thousand distinct objects however many were
/// asked for. Measured after the fact: `packed-1000000o-1p`'s `oids.bin` held
/// **1 000 000 entries and 16 712 distinct ids.** `gunnar.lookup_1m` was
/// resolving oids against a **sixteen-thousand**-object index, small enough to
/// sit in L2, and calling it a million. Every absolute number that arm published
/// before this date is against that corpus and is not comparable with anything
/// after it.
///
/// This is the same disease as the `ratio_x` fixture bug already recorded in
/// the bench spec (a generator whose output silently collapses), and it was caught the
/// same way, by an assertion on an **applied output** rather than on the
/// request: the multi-pack index reported how many objects it actually held.
///
/// The counter goes in as its own line, so the deflate profile of the rest is
/// untouched and only uniqueness changes.
///
/// Lands as one pack, for the same reason [`build_history`] does.
fn build_blobs(
store: &dyn GitOps,
hash: Kind,
seed: u64,
n: usize,
bytes: usize,
) -> Result<Vec<ObjectId>> {
let mut pack = PackBuilder::new(hash);
let mut rng = Lcg::new(seed);
let mut ids = Vec::with_capacity(n);
for i in 0..n {
let mut content = format!("object {i} of {n}\n").into_bytes();
content.extend_from_slice(&rng.text(bytes.saturating_sub(content.len().min(bytes))));
ids.push(pack.add(gix_object::Kind::Blob, content)?);
}
let sealed = pack.finish().context("sealing the blob fixture pack")?;
store
.put_pack(&sealed)
.context("landing the blob fixture pack in the store")?;
Ok(ids)
}
/// Object ids that are **well-formed and certainly absent**: the miss set.
///
/// Derived by flipping the high byte of a real id rather than by hashing random
/// bytes, so they are the same width and the same kind and cannot be rejected
/// early by a cheap shape check. A miss set a store can dismiss without probing
/// its pack indices measures nothing (lookup is O(packs) only on a miss).
fn miss_ids(from: &[ObjectId], n: usize) -> Vec<ObjectId> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let src = from[i % from.len()];
let mut bytes = src.as_slice().to_vec();
bytes[0] ^= 0xff;
bytes[1] ^= (i & 0xff) as u8;
out.push(ObjectId::from_bytes_or_panic(&bytes));
}
out
}
/// Open (or lazily create) a cached on-disk repository fixture.
///
/// `build` is called only when the marker file is absent, so the expensive
/// generation happens once per machine and every later run measures gunnar rather
/// than the fixture generator. The marker is written **last**, so an interrupted
/// generation is retried rather than silently reused half-built.
fn cached_repo(name: &str, build: impl FnOnce(&Path) -> Result<()>) -> Result<PathBuf> {
let dir = fixture_root().join(name);
let marker = dir.join(".gunnar-bench-complete");
if marker.exists() {
return Ok(dir);
}
if dir.exists() {
std::fs::remove_dir_all(&dir).with_context(|| format!("clearing half-built {dir:?}"))?;
}
std::fs::create_dir_all(&dir).with_context(|| format!("creating {dir:?}"))?;
build(&dir).with_context(|| format!("building fixture {name}"))?;
std::fs::write(&marker, b"complete\n")?;
Ok(dir)
}
/// The directory an in-process znippy store puts itself in, **inside** the
/// repository it is the object storage for. `gunnar_bench::store_bytes`'s
/// `ZNIPPY_DIR`, repeated here because that constant is private to the counter
/// and this is the only other place that has to recognise the shape.
const ENGINE_STORE_DIR: &str = "gunnar-git.znippy";
/// [`cached_repo`] for a fixture that has to stay **git-native**, and the reason
/// three head-to-head arms could not pass on any znippy column.
///
/// # What goes wrong
///
/// The `h2h-*` fixtures are git repositories built by `git` itself and served
/// two ways: by stock `git upload-pack`, and by seam 1, whose `prepare` ingests
/// them into a store **beside** the repository (`<repo>.gunnar-<engine>`). So
/// the repository itself is git's and stays git's, and `h2h_corpus` →
/// `store_bytes::classify` is entitled to assume it.
///
/// An arm that calls [`gunnar_bench::engine::open_repo`] **on the fixture
/// directory itself** breaks that: on the znippy engine `open_repo` creates
/// `gunnar-git.znippy/` inside it. The fixture now carries git's `objects/pack`
/// AND a znippy store, and `classify` refuses it — correctly, and by design:
///
/// ```text
/// <fixture> carries BOTH a gunnar-git.znippy/ store and packs in
/// objects/pack. That is two engines' layouts in one repository, and any
/// single number taken off it is one engine's bytes labelled with both
/// engines' names.
/// ```
///
/// The refusal lands on whichever arm serves that fixture **next**, which is
/// never the arm that wrote the store. **Seen red on the desktop 2026-08-14** by
/// disabling the repair below and running the real pair,
/// `NORNIR_BENCH_ONLY=gunnar.clone_no_ofs_delta,gunnar.vs_git_push` on
/// znippy_fast — both arms went red, and the second one verbatim:
///
/// ```text
/// ✗ gunnar.clone_no_ofs_delta: the churned fixture has no refs/heads/main
/// ✗ gunnar.vs_git_push: …/h2h-linear-sha1-2048c-1024f-16k carries BOTH a
/// gunnar-git.znippy/ store and packs in objects/pack. That is two engines'
/// layouts in one repository, and any single number taken off it is one
/// engine's bytes labelled with both engines' names.
/// what is actually there:
/// gunnar-git.znippy/ 2 file(s), 3686400 byte(s)
/// objects/pack/ 4 file(s), 7606732 byte(s)
/// ```
///
/// Both halves of the mechanism are in that one run: `clone_no_ofs_delta` reads
/// the **empty** store it has just caused to be created (hence "no
/// refs/heads/main" on a fixture that has three refs), and `vs_git_push` — which
/// hands the very same directory to `h2h_run` as its `GUNNAR_BENCH_H2H_CLIENT`
/// corpus — is refused by the classifier for the wreckage left behind.
///
/// And the control, same box, same day: with
/// `NORNIR_BENCH_ONLY=gunnar.vs_git_clone` on a **fresh** fixture root,
/// `gunnar.vs_git_clone` passes on znippy_fast (`pack_ratio_x` 0.997, 36 172
/// objects, `gunnar_recompressed` 0) — the same arm that is red on all four
/// znippy columns of a full sweep, where `gunnar.clone_no_ofs_delta` opens a
/// store on the shared sha1 fixture long before it runs. Nothing about the arm
/// changed between those two runs except who had touched the fixture first.
///
/// That is also exactly why `vs_git_clone_sha256` and `vs_git_fetch_divergent`
/// were the two that went green when seam 1 was first set: their fixtures
/// (sha256, divergent) have no in-process reader at all. The three that stayed
/// red — `vs_git_clone`, `vs_git_push`, `vs_git_clone_concurrent` — are exactly
/// the three that share the sha1 one.
///
/// # Why removing the store is the fix and not vandalism
///
/// The store is a *derived* artefact sitting inside a *cached* one, and the two
/// were never meant to share a directory. Deleting it restores the fixture
/// byte-for-byte — `open_repo` writes only `{ENGINE_STORE_DIR}/` and never
/// touches `objects/`, `refs/` or `HEAD` — so this costs nothing and rebuilds
/// nothing. `xtask`'s `cleanup_fixtures` already deletes these wholesale
/// between store passes for the same reason; this is the same policy applied
/// between *arms*, which is where the damage actually happens.
///
/// It is loud on purpose: a fixture that had to be repaired is a real defect in
/// whichever arm wrote the store, and a silent repair would retire the evidence.
fn cached_git_repo(name: &str, build: impl FnOnce(&Path) -> Result<()>) -> Result<PathBuf> {
let dir = cached_repo(name, build)?;
let store = dir.join(ENGINE_STORE_DIR);
if store.exists() {
eprintln!(
"bench: {} carried a {ENGINE_STORE_DIR}/ store written by an in-process arm; \
removing it so this git-native fixture can be served. An arm called \
engine::open_repo on the fixture itself instead of on a copy of it.",
dir.display()
);
std::fs::remove_dir_all(&store)
.with_context(|| format!("removing the foreign engine store at {store:?}"))?;
}
Ok(dir)
}
fn result(name: &str, metrics: Vec<(&str, Value)>) -> BenchResult {
let mut m = Map::new();
for (k, v) in metrics {
m.insert(k.to_string(), v);
}
BenchResult::measured(name, m)
}
// ───────────────────────────────────────────────────────────────────────────
// gunnar.capabilities: static machine facts, gate-exempt
// ───────────────────────────────────────────────────────────────────────────
/// Machine facts, so every other row in the run is interpretable.
///
/// **Red condition: never.** This row is tagged `source: static`, which the
/// no-regression gate skips. An L3 cache that "shrank" because the run moved to
/// a different host is not a performance regression, and treating it as one would
/// train everyone to ignore the gate. Its job is to make the *other* rows
/// falsifiable: `hash_mbs` without `sha_ni` is a different number.
struct Capabilities;
impl Bencher for Capabilities {
fn id(&self) -> &'static str {
"gunnar.capabilities"
}
fn run(&self) -> Result<BenchResult> {
let mut r = nornir_bench::machine_capabilities(self.id());
let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").unwrap_or_default();
let flag = |f: &str| {
let needle = format!(" {f} ");
if cpuinfo
.lines()
.any(|l| l.starts_with("flags") && format!("{l} ").contains(&needle))
{
1.0
} else {
0.0
}
};
let l3_kb = std::fs::read_to_string("/sys/devices/system/cpu/cpu0/cache/index3/size")
.ok()
.and_then(|s| {
let s = s.trim();
let (num, unit) = s.split_at(s.len().saturating_sub(1));
num.parse::<f64>().ok().map(|n| match unit {
"M" => n * 1024.0,
"G" => n * 1024.0 * 1024.0,
_ => n,
})
})
.unwrap_or(0.0);
r.metrics.insert("sha_ni".into(), json!(flag("sha_ni")));
r.metrics.insert("avx2".into(), json!(flag("avx2")));
r.metrics.insert("l3_kb".into(), json!(l3_kb));
r.metrics.insert(
"hash_kind_sha256".into(),
json!(if HASH == Kind::Sha256 { 1.0 } else { 0.0 }),
);
r.metrics
.insert("preview".into(), json!(if preview() { 1.0 } else { 0.0 }));
// ── WHICH WORKTREE, on the row ────────────────────────────────────
//
// `nornir bench run --config <p>` resolves the repository by NAME out
// of the config's grandparent directory, and has therefore benched a
// different worktree from the one it was launched in at least three
// times — caught once only by this file's own RAGNARÖK guard, and once
// not caught at all (run `4b65a303`).
// Nothing on any persisted row said which tree a figure came from.
//
// `CARGO_MANIFEST_DIR` is fixed at COMPILE time, so it names the tree
// this binary was built from — which is exactly the thing that can
// differ from the tree the operator believes they are measuring. A path
// is a string and strings do not reach a `bench_runs` row (measured
// 2026-08-04), so it goes on as a hash: two worktrees give two numbers,
// and a row can be compared against the tree that is supposed to have
// produced it. `bench_binary_mtime` distinguishes two builds of the
// same tree.
r.metrics
.insert("bench_tree_id".into(), json!(bench_tree_id() as f64));
r.metrics.insert(
"bench_binary_mtime".into(),
json!(std::env::current_exe()
.and_then(std::fs::metadata)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0) as f64),
);
Ok(r)
}
}
nornir_bench::register_bench!(Capabilities);
/// The source tree this binary was built from, as a number.
///
/// FNV-1a over `CARGO_MANIFEST_DIR`'s parent, truncated to 2^48 so it survives
/// the `f64` a warehouse metric is stored as without losing a bit. Collision
/// resistance is not the property wanted here — telling two of the project's
/// forty-odd gunnar worktrees apart is.
fn bench_tree_id() -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bench_tree().to_string_lossy().as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
h & 0xffff_ffff_ffff
}
/// The gunnar checkout this binary was built from. Compile-time, so it cannot
/// be talked out of it by an environment variable at run time.
fn bench_tree() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(Path::new(env!("CARGO_MANIFEST_DIR")))
}
// ───────────────────────────────────────────────────────────────────────────
// gunnar.pack_deflate: the deflate-backend guard
// ───────────────────────────────────────────────────────────────────────────
/// Deflate a fixed object corpus through the real [`gunnar_pack::PackWriter`].
///
/// **Red condition: `ratio_x` must not move.** `deflate_mbs` going up is the
/// point of swapping the zlib backend (miniz_oxide for zlib-rs), but a backend that
/// is faster *because it compresses worse* is not an improvement, it is a
/// bandwidth regression wearing a throughput number. The pair is the guard: a
/// `ratio_x` change of more than a percent or two alongside a `deflate_mbs`
/// improvement means the swap traded quality for speed and the trade was not
/// declared.
///
/// A secondary red: `deflate_mbs` at or near the memcpy rate of the machine
/// (tens of GB/s) means nothing was compressed at all.
struct PackDeflate;
impl Bencher for PackDeflate {
fn id(&self) -> &'static str {
"gunnar.pack_deflate"
}
fn run(&self) -> Result<BenchResult> {
// A fixed corpus: 256 MiB in 16 KiB objects, which is roughly the shape of
// a real repository's blob-size distribution's fat middle.
let n = env_usize("GUNNAR_BENCH_DEFLATE_OBJECTS", scaled(16_384, 512));
let size = env_usize("GUNNAR_BENCH_DEFLATE_SIZE", 16 * 1024);
let mut rng = Lcg::new(0xDEF1A7E);
let corpus: Vec<Vec<u8>> = (0..n).map(|_| rng.text(size)).collect();
let raw: u64 = corpus.iter().map(|c| c.len() as u64).sum();
let opts = WriteOptions::new(HASH);
let sink = Sink::default();
let start = Instant::now();
let mut writer = gunnar_pack::PackWriter::new(sink, n as u32, &opts)?;
for object in &corpus {
writer.add_object(gix_object::Kind::Blob, object)?;
}
let outcome = writer.finish()?;
let secs = start.elapsed().as_secs_f64();
ensure!(
outcome.num_objects as usize == n,
"the writer reported {} objects for a {n}-object corpus",
outcome.num_objects
);
ensure!(
outcome.bytes_written < raw,
"the pack ({} B) is not smaller than its input ({raw} B). Nothing was compressed.",
outcome.bytes_written
);
Ok(result(
self.id(),
vec![
("deflate_mbs", json!(round(mbs(raw, secs), 2))),
(
"ratio_x",
json!(round(raw as f64 / outcome.bytes_written as f64, 4)),
),
("objects", json!(n as f64)),
("bytes_in", json!(raw as f64)),
("bytes_out", json!(outcome.bytes_written as f64)),
("compression_level", json!(opts.compression_level as f64)),
],
))
}
}
nornir_bench::register_bench!(PackDeflate);
// ───────────────────────────────────────────────────────────────────────────
// gunnar.pack_trailer_hash: the trailer-hash guard
// ───────────────────────────────────────────────────────────────────────────
/// Hash a fixed byte stream with the same hasher every pack's trailer uses.
///
/// **Red condition: `hash_mbs` far below the machine's memory bandwidth while
/// `gunnar.capabilities.sha_ni` is 1.** On a host that advertises `sha_ni`, a
/// sha256 rate in the low hundreds of MB/s means the accelerated path is not
/// being taken and the software fallback is, which costs 4.9x on the hash, and it is
/// invisible without this pair of rows.
///
/// The stream is hashed in 64 KiB chunks because that is `DEFLATE_CHUNK`, the
/// unit `PackWriter` actually feeds the hasher. Hashing one giant slice would
/// measure a call gunnar never makes.
struct PackTrailerHash;
impl Bencher for PackTrailerHash {
fn id(&self) -> &'static str {
"gunnar.pack_trailer_hash"
}
fn run(&self) -> Result<BenchResult> {
let chunk_kib = 64usize;
let total = env_usize("GUNNAR_BENCH_HASH_MB", scaled(2048, 64)) * 1024 * 1024;
let mut rng = Lcg::new(0x8A5_1E5);
let chunk = rng.text(chunk_kib * 1024);
let chunks = total / chunk.len();
let start = Instant::now();
let mut hasher = gix_hash::hasher(HASH);
for _ in 0..chunks {
hasher.update(&chunk);
}
let id = hasher.try_finalize()?;
let secs = start.elapsed().as_secs_f64();
let hashed = (chunks * chunk.len()) as u64;
ensure!(
id.kind() == HASH,
"the trailer hash is {:?}, not the repository's {HASH:?}",
id.kind()
);
Ok(result(
self.id(),
vec![
("hash_mbs", json!(round(mbs(hashed, secs), 2))),
("bytes", json!(hashed as f64)),
("chunk_kib", json!(chunk_kib as f64)),
],
))
}
}
nornir_bench::register_bench!(PackTrailerHash);
// ───────────────────────────────────────────────────────────────────────────
// gunnar.clone_no_ofs_delta: what a client without `ofs-delta` pays
// ───────────────────────────────────────────────────────────────────────────
/// The same clone twice, once with the client's `ofs-delta` capability and once
/// without, over a **churned** history where delta reuse has something to reuse.
///
/// The pack-copy path is gated on `ofs-delta` (`upload_pack.rs`), because a
/// copied delta is re-pointed as an `OBJ_OFS_DELTA` and a client that cannot read
/// one would be handed an unreadable pack. Every modern `git` sends the
/// capability, so the head-to-head arms never exercise the other branch — and the
/// other branch, until this arm was written, was **the entire server as it was
/// before pack-copy**: no delta reuse, no entry copying, a fresh deflate of every object.
///
/// This arm prices that, on the fixture where it costs the most. It runs
/// in-process against `gunnar-wire` directly, because the capability is a
/// property of the request and there is no way to make stock `git` withhold it.
///
/// # The subject is the CLIENT'S REPOSITORY, not the server's encoding
///
/// Raised 2026-08-14. What this arm asks is *"does a client that never
/// advertised `ofs-delta` still end up with the right repository, at a sane
/// cost?"* — and both halves of that are checked against something outside the
/// server:
///
/// * **`objects_delivered_x` — the object sets.** Each of the two packs is
/// handed to **stock `git index-pack --strict`**, and the ids it writes into
/// the resulting `.idx` are compared against `git rev-list --objects` over the
/// fixture itself. The two clones must deliver *the same* set and it must be
/// *the right* set. That is the assertion that fails if the no-`ofs-delta`
/// branch drops, duplicates or corrupts an object, and it is checked with git,
/// not with a number the server reported about itself.
/// * **`no_ofs_bytes_x` — the cost.** At most 1.20.
///
/// Deliberately **not** asserted any more: which entry type the server chose.
/// `OBJ_OFS_DELTA` against `OBJ_REF_DELTA` is *how* an engine answers the
/// capability, and the two engines are entitled to answer it differently — or,
/// for a store that keeps no packfile at all, to have no stored spelling to
/// re-encode in the first place. A client cannot see the difference except in
/// the byte count, which is exactly what `no_ofs_bytes_x` measures. So this arm
/// no longer distinguishes the two engines' internals, and that is the point:
/// the same question is now put to both of them honestly.
///
/// **Red condition: `no_ofs_bytes_x` must be at most 1.20.** A client that cannot
/// read `ofs-delta` can still read `OBJ_REF_DELTA` — it is the older of the two
/// forms and needs no capability at all — so there is no reason for it to receive
/// a materially larger pack. Seen red at `735728c`, where it read **18.336×**.
///
/// The ceiling is 1.20 rather than 1.10 because the residue is not noise, it is
/// arithmetic: one full base id per delta entry in place of a varint distance,
/// which at sha1 is 20 bytes against 1-3. MEASURED 2026-08-03 on this fixture:
/// **1.095×**, i.e. 591 093 bytes over 6 216 589 for ~33 000 delta entries.
/// MEASURED again the desktop 2026-08-14 on the gix column, through the re-heading
/// emitter this arm's old red asked for: **1.098×**, 6 829 475 over 6 222 710
/// for 36 172 objects, `recompressed = 0`. A sha256 repository pays 32 bytes and
/// would sit higher, which is why a 1.10 ceiling would have been a guard that
/// flaps rather than a guard that holds.
/// `crates/gunnar-pack/tests/ofs_delta_off.rs` asserts the derived bound
/// entry-by-entry, where the delta count is known; this arm asserts the shape.
struct CloneNoOfsDelta;
impl Bencher for CloneNoOfsDelta {
fn id(&self) -> &'static str {
"gunnar.clone_no_ofs_delta"
}
fn run(&self) -> Result<BenchResult> {
// The churned head-to-head fixture, not the eleven arms' random blobs: a
// corpus that deltas to nothing cannot show a delta-reuse difference, and
// would report 1.00x for a completely broken implementation.
let source = h2h_linear_fixture(false)?;
// **The truth this arm is checked against, and it is git's.** Read off
// the fixture before any engine touches it, so it is a property of the
// corpus rather than of whichever store is under test.
let expected = h2h_object_set(&source)?;
// Handed to whichever engine is selected, in that engine's own layout.
// Without this the arm asked znippy to read a `.git` directory and got
// "the churned fixture has no refs/heads/main" — a true statement about
// an empty store, and nothing at all about the capability under test.
let dir = engine_served(&source, "clone-no-ofs-delta")?;
let store = gunnar_bench::engine::open_repo(&dir, Kind::Sha1)?;
let git = store.git();
let tip = read_ref(git.as_ref(), "refs/heads/main")?
.context("the churned fixture has no refs/heads/main")?;
let judged = fixture_root().join("no-ofs-judged");
let _ = std::fs::remove_dir_all(&judged);
std::fs::create_dir_all(&judged)?;
let mut rows = Vec::new();
for ofs in [true, false] {
let counting = Counting::new(git.clone());
let upload = UploadPack::new(&counting, HASH);
let request = FetchRequest {
wants: vec![tip],
done: true,
ofs_delta: ofs,
..Default::default()
};
// A `Vec`, where this used to be a counting `Sink`: the byte count
// is the same number, and keeping the bytes is what lets stock git
// judge the clone instead of the server grading its own work.
let mut sink: Vec<u8> = Vec::new();
let start = Instant::now();
let outcome = upload.fetch(&request, &mut sink)?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
ensure!(
outcome.sent_pack,
"the ofs_delta={ofs} fetch produced no pack"
);
let bytes = sink.len() as u64;
let delivered = objects_stock_git_accepts(
&gunnar_bench::stages::demux_packfile(&sink)?,
&judged,
if ofs { "ofs" } else { "no-ofs" },
// The sha1 head-to-head fixture, not `HASH`.
Kind::Sha1,
)?;
rows.push((ofs, bytes, ms, outcome, delivered));
}
let (_, ofs_bytes, ofs_ms, ofs_out, ofs_ids) = rows[0].clone();
let (_, raw_bytes, raw_ms, raw_out, raw_ids) = rows[1].clone();
// ── the clone the client actually ends up with ──────────────────────
//
// Three statements, and none of them is the server's own count. The
// first is the one this arm is FOR: withholding a capability must not
// change which objects arrive.
ensure!(
raw_ids == ofs_ids,
"the two clones delivered different object sets — {} ids with ofs-delta and {} \
without, {} present in only one of them — so the byte ratio below compares two \
different repositories and the client that cannot read ofs-delta got the wrong one",
ofs_ids.len(),
raw_ids.len(),
ofs_ids.symmetric_difference(&raw_ids).count()
);
ensure!(
raw_ids == expected,
"stock git indexed {} objects out of the no-ofs-delta clone where `git rev-list \
--objects` says the fixture reaches {} ({} missing, {} unexpected)",
raw_ids.len(),
expected.len(),
expected.difference(&raw_ids).count(),
raw_ids.difference(&expected).count()
);
ensure!(
ofs_out.objects_sent == raw_out.objects_sent && ofs_out.objects_sent > 0,
"the two requests sent {} and {} objects, so the byte ratio compares two \
different clones",
ofs_out.objects_sent,
raw_out.objects_sent
);
let bytes_x = raw_bytes as f64 / ofs_bytes.max(1) as f64;
ensure!(
bytes_x <= 1.20,
"a client without ofs-delta was served {raw_bytes} bytes where one with it got \
{ofs_bytes} — {bytes_x:.3}x — and {} of {} entries were inflated and deflated \
again ({raw_ms:.0} ms against {ofs_ms:.0} ms). OBJ_REF_DELTA needs no \
capability, so a copied delta can be re-pointed by id instead of by distance \
and this ratio has no reason to be above 1.0.",
raw_out.objects_recompressed,
raw_out.objects_sent
);
// **The guard that keeps the ratio above from being a beautiful 1.00
// over two fallbacks**, raised to something outside the server.
//
// What stood here was `objects_recompressed == 0 &&
// objects_copied_from_pack > 0` — the server's own receipt for its own
// work, and a mechanism: an engine that keeps no packfile has no stored
// entry to "copy" and would fail a guard about copying while serving a
// perfectly good clone. The observable form of the same failure is that
// a server which inflates and re-deflates every object emits a pack
// enormously larger than the one `git repack -adfq` already wrote for
// the same objects — **18.4×**, measured. So the fixture's own packs are
// the yardstick, and 2.0 is far below the failure and far above the
// spread between two honest packers.
let git_pack_bytes = pack_bytes_in(&source.join("objects/pack"))?;
let vs_git_x = ofs_bytes as f64 / git_pack_bytes.max(1) as f64;
ensure!(
vs_git_x <= 2.0,
"the ofs-delta clone was {ofs_bytes} bytes where `git repack -adfq` stored the same \
history in {git_pack_bytes} — {vs_git_x:.3}x. A server on the delta-reuse path emits \
about what git stored; one that inflates and re-deflates every object emits about \
18x, and the ratio below would then compare two fallbacks and report 1.00"
);
Ok(result(
self.id(),
vec![
("no_ofs_bytes_x", json!(round(bytes_x, 3))),
("no_ofs_ms_x", json!(round(raw_ms / ofs_ms.max(0.001), 3))),
("bytes_out_ofs", json!(ofs_bytes as f64)),
("bytes_out_no_ofs", json!(raw_bytes as f64)),
("clone_ms_ofs", json!(round(ofs_ms, 2))),
("clone_ms_no_ofs", json!(round(raw_ms, 2))),
("ofs_vs_git_pack_x", json!(round(vs_git_x, 3))),
(
"copied_no_ofs",
json!(raw_out.objects_copied_from_pack as f64),
),
(
"recompressed_no_ofs",
json!(raw_out.objects_recompressed as f64),
),
("objects_sent", json!(ofs_out.objects_sent as f64)),
("objects_delivered", json!(raw_ids.len() as f64)),
],
))
}
}
nornir_bench::register_bench!(CloneNoOfsDelta);
// ───────────────────────────────────────────────────────────────────────────
// gunnar.push_large: the parallel-indexing guard
// ───────────────────────────────────────────────────────────────────────────
/// Receive, index and connectivity-check a large pack through the real
/// [`ReceivePack::serve`] path.
///
/// **Red condition: `parallel_speedup_x` at 1.0.** The defect is that `gix-pack`'s
/// `parallel` feature is never declared, so the appliance build silently indexes
/// packs on the serial code path. The honest in-process probe for that is to index
/// the *same* pack twice, once with `thread_limit(1)` and once with the default,
/// and compare: if the two throughputs are the same, the parallel path is not
/// engaging and the feature, however it is declared, did nothing.
///
/// This is a runtime probe, not a build-configuration comparison. Running gunnar
/// in *two cargo feature configurations* is not possible from one detached bench
/// workspace: this crate links `gunnar-store/fs`, which enables `gix-odb/parallel`,
/// which enables `gix-features/parallel` for the whole graph. Cargo unifies
/// features per graph, so the serial configuration cannot be reached from here at
/// all. What is unmeasured is stated rather than faked.
///
/// Secondary red: `objects` not equal to the number of objects in the pack means
/// the index silently shortened it.
struct PushLarge;
impl Bencher for PushLarge {
fn id(&self) -> &'static str {
"gunnar.push_large"
}
fn run(&self) -> Result<BenchResult> {
let commits = env_usize("GUNNAR_BENCH_PUSH_COMMITS", scaled(1024, 64));
let blob_bytes = env_usize("GUNNAR_BENCH_PUSH_BLOB_KIB", scaled(512, 64)) * 1024;
// A store the pushed history is already visible in. `receive-pack`'s
// connectivity check reads the new tip's closure out of the store, and
// the store's own ingest→visibility step is a storage concern, not
// part of what this arm times.
let store = MemGitServe::new(HASH);
let corpus = build_history(&store, HASH, 0x9457, commits, 1, blob_bytes, None)?;
let n_objects = corpus.ids.len();
// The pack the client would send.
let odb = GunnarObjects(&store);
let (pack, outcome) =
gunnar_pack::write_pack_to_vec(&corpus.ids, &odb, &WriteOptions::new(HASH))?;
ensure!(
outcome.num_objects as usize == n_objects,
"the fixture pack is short"
);
// ── the two index passes: serial vs default fan-out ────────────────
let index_once = |threads: Option<usize>| -> Result<(f64, u32)> {
let mut opts = IndexOptions::new(HASH);
if let Some(t) = threads {
opts = opts.thread_limit(t);
}
let mut reader = std::io::Cursor::new(&pack[..]);
let start = Instant::now();
let indexed = gunnar_pack::index_pack(&mut reader, None, &opts, None)?;
Ok((start.elapsed().as_secs_f64(), indexed.num_objects))
};
let (serial_secs, serial_objects) = index_once(Some(1))?;
let (par_secs, par_objects) = index_once(None)?;
ensure!(
serial_objects as usize == n_objects && par_objects as usize == n_objects,
"indexing reported {serial_objects}/{par_objects} objects for a {n_objects}-object pack"
);
// ── the full receive: advertisement → commands → pack → status ─────
let mut request = Vec::with_capacity(pack.len() + 256);
let mut line = format!(
"{} {} refs/heads/main",
HASH.null().to_hex(),
corpus.tip.to_hex()
)
.into_bytes();
line.push(0);
line.extend_from_slice(
format!(
"report-status object-format={} agent=gunnar-bench/1",
object_format(HASH)
)
.as_bytes(),
);
gunnar_pktline::write_text(&mut request, &line)?;
gunnar_pktline::write_flush(&mut request)?;
request.extend_from_slice(&pack);
let counting = Counting::new(&store);
let receive = ReceivePack::new(&counting, HASH);
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
let start = Instant::now();
let push = receive
.serve(
&mut input,
&mut out,
&|pack: &mut dyn BufRead| {
gunnar_pack::index_pack(pack, None, &IndexOptions::new(HASH), None)
.map(wire_indexed)
.map_err(wire_ingest_err)
},
&AllowAll,
)?
.context("the push produced no outcome")?;
let push_secs = start.elapsed().as_secs_f64();
// A push that was refused is very cheap and completely useless as a
// measurement (the shape `connectivity_cost.rs` already guards against).
ensure!(
push.statuses == vec![RefStatus::Ok],
"the push was refused ({:?}), so what was timed is the cost of refusing it",
push.statuses
);
ensure!(
read_ref(&store, "refs/heads/main")? == Some(corpus.tip),
"the reference did not move, so nothing was really accepted"
);
ensure!(
counting.touches() as usize >= n_objects,
"the connectivity check touched {} objects for a {n_objects}-object push, so it \
did not walk the pushed history",
counting.touches()
);
let speedup = if par_secs > 0.0 {
serial_secs / par_secs
} else {
0.0
};
Ok(result(
self.id(),
vec![
("push_ms", json!(round(push_secs * 1000.0, 2))),
(
"index_mbs",
json!(round(mbs(pack.len() as u64, par_secs), 2)),
),
(
"index_mbs_serial",
json!(round(mbs(pack.len() as u64, serial_secs), 2)),
),
("parallel_speedup_x", json!(round(speedup, 3))),
("objects", json!(n_objects as f64)),
("pack_bytes", json!(pack.len() as f64)),
("connectivity_touches", json!(counting.touches() as f64)),
],
))
}
}
nornir_bench::register_bench!(PushLarge);
fn object_format(kind: Kind) -> &'static str {
match kind {
Kind::Sha1 => "sha1",
_ => "sha256",
}
}
// ───────────────────────────────────────────────────────────────────────────
// gunnar.walk_1m: the serial closure walk
// ───────────────────────────────────────────────────────────────────────────
/// Closure walk over a million-object graph.
///
/// Driven through `receive-pack`'s connectivity check rather than through a
/// clone, because that path walks the closure and **writes no pack**, so the
/// number is the walker's, not the deflater's. It is the same walker either way
/// (`gunnar-wire/src/graph.rs`, one walker for both directions), which is
/// exactly why it can be measured from the cheaper side.
///
/// **Red condition: fewer object reads than there are commits and trees.** A
/// walk that reports a million objects per second because it visited eight of
/// them is the failure mode here, and `objects_per_sec` alone cannot see it. The
/// counting store makes the claim falsifiable: a closure over C commits and T
/// trees cannot be established without reading each of them at least once, and
/// that is asserted per kind, not hoped for.
///
/// **It used to assert `touches >= objects`, and that was wrong — corrected on
/// 2026-08-03.** The floor was met only because the walk decoded every commit
/// **twice**: once in `commit_closure` for its parents, once again afterwards to
/// ask for the root tree it had already read and thrown away. `touches` came to
/// 1 000 639 on a 1 000 002-object closure, and 333 334 of those reads — one per
/// commit, a third of the total — were the redundant ones. Removing them made
/// this arm go RED at 667 305, so the old floor had quietly encoded the
/// inefficiency as the invariant: the only way to satisfy it was to keep doing
/// the extra work. A floor that a fix can violate is not a floor, and blobs
/// never contributed to it at all.
///
/// **The ceiling is the new half, and it is what guards the walk.**
/// `commit_reads <= commits` says each commit is read at most once. The
/// walker as it stood before reads each one exactly twice and fails it: measured, not
/// assumed — `gunnar.walk_1m` at `e4d8a78` reports `commit_reads_per_commit`
/// 2.000, and after the fix 1.000.
///
/// In memory on purpose: this is a question about traversal cost, and running
/// it against a filesystem would measure page-cache behaviour instead.
struct Walk1m;
impl Bencher for Walk1m {
fn id(&self) -> &'static str {
"gunnar.walk_1m"
}
fn run(&self) -> Result<BenchResult> {
// 333_334 commits x (1 blob + 1 tree + 1 commit) ≈ 1.0 M objects.
let commits = env_usize("GUNNAR_BENCH_WALK_COMMITS", scaled(333_334, 6_667));
let store = MemGitServe::new(HASH);
let corpus = build_history(&store, HASH, 0x3A1C, commits, 1, 64, None)?;
let n_objects = corpus.ids.len();
let counting = Counting::new(&store);
let receive = ReceivePack::new(&counting, HASH);
let mut request = Vec::new();
let mut line = format!(
"{} {} refs/heads/main",
HASH.null().to_hex(),
corpus.tip.to_hex()
)
.into_bytes();
line.push(0);
line.extend_from_slice(
format!(
"report-status object-format={} agent=gunnar-bench/1",
object_format(HASH)
)
.as_bytes(),
);
gunnar_pktline::write_text(&mut request, &line)?;
gunnar_pktline::write_flush(&mut request)?;
// An empty pack: every object is already in the store, so the push is
// pure connectivity verification and the walk is all that is timed.
request.extend_from_slice(&gunnar_pack::empty_pack(HASH)?);
let mut input = std::io::Cursor::new(request);
let mut out = Vec::new();
let start = Instant::now();
let push = receive
.serve(
&mut input,
&mut out,
&|pack: &mut dyn BufRead| {
gunnar_pack::index_pack(pack, None, &IndexOptions::new(HASH), None)
.map(wire_indexed)
.map_err(wire_ingest_err)
},
&AllowAll,
)?
.context("the connectivity push produced no outcome")?;
let secs = start.elapsed().as_secs_f64();
ensure!(
push.statuses == vec![RefStatus::Ok],
"the connectivity check rejected the tip ({:?}), so the walk did not complete",
push.statuses
);
let touches = counting.touches();
let commit_reads = counting.commit_reads();
let tree_reads = counting.tree_reads();
// THE FLOOR: the walk really did reach the whole graph.
ensure!(
commit_reads >= corpus.commits as u64 && tree_reads >= corpus.trees as u64,
"the walk read {commit_reads} commits and {tree_reads} trees for a closure of \
{} commits and {} trees; it did not reach the whole graph, so objects_per_sec \
describes a partial walk (touches {touches}, objects {n_objects})",
corpus.commits,
corpus.trees
);
// THE CEILING: and it reached it without reading anything twice.
//
// `+ TIPS` is not slack, it is the one ref this push advertises:
// `verify_connectivity` peels every tip before walking, and peeling
// reads the object. One tip, one extra read, and it must be spelled out
// rather than absorbed into a tolerance — a ceiling with room in it
// could not tell 1 read per commit from 1.5.
const TIPS: u64 = 1;
ensure!(
commit_reads <= corpus.commits as u64 + TIPS,
"the walk read {commit_reads} commits for a closure of {} — {:.3} reads per \
commit. Every commit past the first read is a store lookup, a zlib inflate and \
a delta chain spent re-deriving a field the walk already had",
corpus.commits,
commit_reads as f64 / corpus.commits as f64
);
Ok(result(
self.id(),
vec![
("objects_per_sec", json!(round(n_objects as f64 / secs, 1))),
("walk_ms", json!(round(secs * 1000.0, 2))),
("objects", json!(n_objects as f64)),
("commits", json!(corpus.commits as f64)),
("trees", json!(corpus.trees as f64)),
("blobs", json!(corpus.blobs as f64)),
("store_touches", json!(touches as f64)),
("commit_reads", json!(commit_reads as f64)),
(
"commit_reads_per_commit",
json!(round(commit_reads as f64 / corpus.commits as f64, 3)),
),
(
"content_mb",
json!(round(corpus.content_bytes as f64 / (1024.0 * 1024.0), 2)),
),
],
))
}
}
nornir_bench::register_bench!(Walk1m);
// ───────────────────────────────────────────────────────────────────────────
// gunnar.lookup_1m / gunnar.lookup_1m_64pack: batch lookup, and O(packs) on a miss
// ───────────────────────────────────────────────────────────────────────────
/// The single-pack miss rate as `gunnar.lookup_1m` measured it, published for the
/// record only.
///
/// **It is deliberately NOT what `lookup_1m_64pack`'s degradation assertion
/// compares against**, and the reason is a hollow guard that was caught here on
/// 2026-08-03 by trying to make it fail:
///
/// Bumped whenever the corpus [`build_blobs`] generates changes, so a cached
/// fixture from before the change is regenerated instead of silently reused.
///
/// `v2` (2026-08-03): every blob is distinct. `v1` asked for a million and
/// produced 16 712. See [`build_blobs`].
///
/// `v3` (2026-08-14): the corpus is landed **through the engine**
/// ([`packed_fixture_shaped`]) instead of being written straight into
/// `objects/pack`. Every `v2` fixture on a znippy root carries a 0-byte
/// `gunnar-git.znippy/objects.pack` — an empty store — and every arm built on
/// one measured a repository with nothing in it. The key is bumped because a
/// cached `v2` directory is not a smaller version of a `v3` one, it is the
/// defect.
const PACKED_CORPUS_VERSION: &str = "v3";
/// How a [`packed_fixture`]'s objects are laid out across its packs, and whether
/// the result can be **served** or only probed.
///
/// # The shape was implicit, and that is how a corruption bug got out
///
/// Until 2026-08-06 [`packed_fixture`] had exactly one shape and never said so:
/// `ids.chunks(per)`, one residency per object, no refs and no commit, so
/// nothing built on it could ever be served to a client. Every OTHER corpus in
/// this file comes from `git clone --bare`, which repacks to a single pack. So
/// the whole suite held two shapes — *one pack* and *N disjoint packs* — and
/// neither of them is the shape a pushed server has.
///
/// `0d10e17`'s defect needs the third: an object living in **two** packs with a
/// multi-pack index over them, which is what `index-pack --fix-thin` produces
/// when it splices a delta base into a later push. No arm and no test could
/// reach it, and a pack stock git refuses (`fatal: pack is corrupted (SHA1
/// mismatch)`) reached a customer build.
///
/// So the shape is now a value, it is written into the fixture's cache key, and
/// it is **measured back off disk** rather than assumed — see
/// [`objects_by_pack_residency`]. A fixture that drifts out of its declared
/// shape now says so instead of quietly changing what its arm is about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PackShape {
packs: usize,
objects: usize,
/// Objects the previous pack already holds that are written into this one
/// as well. `0` is the historical disjoint shape.
///
/// **This is the only knob that makes a multi-pack index matter.** The index
/// keeps one deduplicated oid table, so it has something to choose between
/// only when an object has two homes; over disjoint packs it costs nothing
/// and hides nothing, which is why 1139-pack `bench/year` clones clean.
overlap: usize,
/// Write a tree and a commit per pack, chained, plus `refs/heads/main`, so
/// the fixture is a repository a client can fetch from rather than a pile of
/// packfiles.
served: bool,
}
impl PackShape {
/// The historical shape: disjoint packs, blobs only, lookups only. Kept
/// deliberately — `gunnar.lookup_1m` and `gunnar.lookup_1m_64pack` are
/// the miss-path series; re-baselining them to a different corpus would throw
/// away every number ever published against them. Its cache key is
/// unchanged, so no existing fixture is rebuilt.
fn probed(packs: usize, objects: usize) -> Self {
PackShape {
packs,
objects,
overlap: 0,
served: false,
}
}
/// The production shape: `overlap` objects shared with the previous pack,
/// and a history over the blobs so the store can be served.
///
/// **No caller since 2026-08-14.** Its one arm was
/// `gunnar.multi_pack_extent_cost`, deleted with the reason recorded where
/// it stood. Kept rather than deleted because it is the only shape in this
/// file that produces a *servable* store with an object in two packs — the
/// corpus `0d10e17`'s defect needs — and building it directly instead of
/// driving sixteen `git push`es is the difference between two seconds and
/// two minutes for whatever asks that question next.
#[allow(dead_code)]
fn served(packs: usize, objects: usize, overlap: usize) -> Self {
PackShape {
packs,
objects,
overlap,
served: true,
}
}
/// The fixture cache key. The probed shape keeps its historical name so the
/// expensive million-object corpora on disk stay valid.
fn cache_name(&self) -> String {
let PackShape {
packs,
objects,
overlap,
served,
} = *self;
if overlap == 0 && !served {
format!("packed-{objects}o-{packs}p-{PACKED_CORPUS_VERSION}")
} else {
format!("packed-{objects}o-{packs}p-ov{overlap}-served-{PACKED_CORPUS_VERSION}")
}
}
}
/// What a built [`packed_fixture_shaped`] turned out to be — every field read
/// back off disk, never restated from the request (measured, not asserted).
struct PackedFixture {
dir: PathBuf,
/// The blobs, in generation order.
ids: Vec<ObjectId>,
/// The tip of the commit chain, when the shape asked to be servable. This
/// is what `stages::serve_on` is given, and its absence is what made every
/// arm built on this fixture unable to look at a served pack at all.
///
/// Unread since 2026-08-14 for the same reason [`PackShape::served`] has no
/// caller. It is still **written and checked** by the builder, so the
/// fixture's own guard ("a servable fixture with no tip") is live.
#[allow(dead_code)]
head: Option<ObjectId>,
/// Objects that live in more than one pack, counted from the `.idx` files.
///
/// Unread by any arm today; the builder still asserts it against the shape
/// that was asked for, which is where it does its work.
#[allow(dead_code)]
duplicated: usize,
}
/// Build a repository whose `objects` objects live in exactly `packs` packfiles.
fn packed_fixture(packs: usize, objects: usize) -> Result<(PathBuf, Vec<ObjectId>)> {
let f = packed_fixture_shaped(PackShape::probed(packs, objects))?;
Ok((f.dir, f.ids))
}
/// Where a [`packed_fixture`] keeps its packs **in git's own layout**, whatever
/// engine is serving the repository beside them.
///
/// Deliberately NOT `objects/pack`. That directory is the gix engine's *store*,
/// and `gunnar_bench::store_bytes::classify` is right to call a repository
/// carrying both it and a `gunnar-git.znippy/` "two engines' layouts in one
/// repository". Writing the corpus there and nowhere else is what made every
/// znippy column of this file measure an empty store: the packs went to disk in
/// git's shape and the engine — which is what an arm then opens and probes —
/// was never handed them.
///
/// Three readers need a v2 `.idx` and are asking about an index **structure**
/// rather than about a store, so they read this directory on both engines and
/// are engine-neutral by construction instead of by accident:
///
/// * [`objects_by_pack_residency`] — the fixture's own shape census;
/// * [`sole_idx`] → `gunnar.oid_index`, which benchmarks `Fanout16` and
/// `OidStree32` against `idx_bsearch` and opens no store at all;
/// * [`count_packs_in`], for the "did this fixture end with the number of packs
/// its name claims" guard.
///
/// The bytes are duplicated on the gix arm — once here, once in `objects/pack`
/// after the engine indexes them — and that is the price of the split: ~166 MiB
/// on the million-object corpus, paid once per machine per store root. The
/// alternative was a fixture that is one engine's layout labelled as both.
fn pack_corpus_dir(dir: &Path) -> PathBuf {
dir.join("pack-corpus")
}
/// [`packed_fixture`] with the shape spelled out. See [`PackShape`].
///
/// The old multi-pack A/B arm that rewrote the store index was retired with the
/// gix engine; this fixture builder remains for serving and lookup arms.
///
/// # The corpus is landed through the ENGINE, and the engine is asked to prove it
///
/// Every pack goes to two places: [`pack_corpus_dir`] in git's own layout, for
/// the arms that read a v2 `.idx`, and `GitOps::put_pack` on the store this
/// process's `GUNNAR_ENGINE` selects, which is what every arm built on this
/// fixture actually probes and serves from. Before 2026-08-14 only the first
/// happened, so on all four znippy columns the store was created, left at a
/// 0-byte `objects.pack`, and measured: `gunnar.lookup_1m` reported *"only 0 of
/// 10000 hit probes were found"* and had done for as long as anyone had run it.
fn packed_fixture_shaped(shape: PackShape) -> Result<PackedFixture> {
let PackShape {
packs,
objects,
overlap,
served,
} = shape;
let name = shape.cache_name();
let ids_path = fixture_root().join(&name).join("oids.bin");
let head_path = fixture_root().join(&name).join("head.txt");
let dir = cached_repo(&name, |dir| {
let store = gunnar_bench::engine::open_repo(dir, HASH)?;
// Generate into memory first: the ids have to exist before a pack can be
// written, and writing a million loose objects only to pack them would
// cost an hour of inodes for nothing.
let mem = MemGitServe::new(HASH);
let ids = build_blobs(&mem, HASH, 0x10_0CD0 + packs as u64, objects, 96)?;
let odb = GunnarObjects(&mem);
let pack_dir = pack_corpus_dir(dir);
std::fs::create_dir_all(&pack_dir)?;
let per = objects.div_ceil(packs);
// The history, when the shape asked for one: one tree and one commit per
// pack, chained, each tree naming that pack's own blobs. A fetch from the
// last commit therefore walks every blob in the store, which is what
// makes the fixture SERVABLE — the thing it could not do before
// 2026-08-06 and the reason no arm built on it had ever looked at bytes
// a client receives.
let mut history: Vec<(ObjectId, ObjectId)> = Vec::new(); // (tree, commit)
if served {
// A second pack, for the graph over the blobs `build_blobs` already
// landed. `GitOps` has no single-object write (see `build_history`),
// so these are sealed and landed together once the loop is done.
let mut graph = PackBuilder::new(HASH);
let mut parent: Option<ObjectId> = None;
for (c, chunk) in ids.chunks(per).enumerate() {
let mut entries: Vec<(String, ObjectId)> = chunk
.iter()
.enumerate()
.map(|(i, id)| (format!("p{c:05}_{i:07}.bin"), *id))
.collect();
// gix rejects a tree whose entries are not sorted by name.
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut tree = Vec::new();
for (n, id) in &entries {
tree.extend_from_slice(b"100644 ");
tree.extend_from_slice(n.as_bytes());
tree.push(0);
tree.extend_from_slice(id.as_bytes());
}
let tree_id = graph.add(gix_object::Kind::Tree, tree)?;
let at = 1_700_000_000i64 + c as i64;
let mut text = format!("tree {}\n", tree_id.to_hex());
if let Some(p) = parent {
text.push_str(&format!("parent {}\n", p.to_hex()));
}
text.push_str(&format!(
"author Gunnar Bench <bench@gunnar.invalid> {at} +0000\n\
committer Gunnar Bench <bench@gunnar.invalid> {at} +0000\n\npack {c}\n"
));
let commit_id = graph.add(gix_object::Kind::Commit, text.as_bytes().to_vec())?;
parent = Some(commit_id);
history.push((tree_id, commit_id));
}
let sealed = graph.finish().context("sealing the fixture's graph pack")?;
mem.put_pack(&sealed)
.context("landing the fixture's graph pack")?;
}
for (c, chunk) in ids.chunks(per).enumerate() {
// The production shape. `index-pack --fix-thin` splices a delta base
// that arrived thin into the pack it completes, so the base ends up
// in two packs at once — and THAT is the only condition under which a
// multi-pack index has to attribute an object to one of them and
// leave the other's entry list short. Reproduce it directly, by
// writing the tail of the previous chunk into this pack as well,
// rather than by driving twelve real pushes.
let mut want: Vec<ObjectId> = chunk.to_vec();
if overlap > 0 && c > 0 {
let prev = &ids[(c - 1) * per..c * per];
want.extend(prev.iter().rev().take(overlap.min(prev.len())).copied());
}
if let Some((tree, commit)) = history.get(c) {
want.push(*tree);
want.push(*commit);
}
let mut buf = Vec::new();
gunnar_pack::write_pack(&want, &odb, &mut buf, &WriteOptions::new(HASH))?;
let mut reader = std::io::Cursor::new(&buf[..]);
gunnar_pack::index_pack(&mut reader, Some(&pack_dir), &IndexOptions::new(HASH), None)?;
// …and the SAME bytes into the store the arms will open. This line
// is the whole of root cause #1: without it a znippy-served fixture
// is a `gunnar-git.znippy/objects.pack` of zero bytes standing
// beside a corpus the engine has never seen, and `GitOps::has`
// answers `false` for every object the fixture is named after.
//
// The engine decides what "stored" means: gix re-indexes into
// `objects/pack` (one pack per call, so the declared shape
// survives), znippy appends the verbatim bytes into its one
// `objects.pack`.
store
.git()
.put_pack(&buf)
.with_context(|| format!("landing pack {c} of {packs} in the store"))?;
}
// `.keep` files stop a repack from collecting a pack whose refs are not
// installed yet. Nothing here repacks, and leaving them makes the
// fixture's shape depend on git's collection policy.
for entry in std::fs::read_dir(&pack_dir)? {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "keep") {
std::fs::remove_file(path)?;
}
}
let mut raw = Vec::with_capacity(ids.len() * HASH.len_in_bytes());
for id in &ids {
raw.extend_from_slice(id.as_slice());
}
std::fs::write(dir.join("oids.bin"), &raw)?;
// A ref, so the store is a repository. `stages::serve_on` names its want
// directly, but a store whose only branch is absent is not the thing a
// client talks to, and the advertisement is half of what a fetch is.
if let Some((_, tip)) = history.last() {
// `RefEdit`/`FullName`/`Target` went with `RefStore`. The contract's
// spelling is a `RefUpdate` row applied through `GitOps::put_refs`,
// and the name is a plain `&str` the engine validates.
store
.git()
.put_refs(&[RefUpdate::set("refs/heads/main", tip.to_hex().to_string())])?;
std::fs::write(dir.join("head.txt"), tip.to_hex().to_string())?;
}
drop(store);
Ok(())
})?;
let raw = std::fs::read(&ids_path).with_context(|| format!("reading {ids_path:?}"))?;
let width = HASH.len_in_bytes();
let ids: Vec<ObjectId> = raw
.chunks_exact(width)
.map(ObjectId::from_bytes_or_panic)
.collect();
// The guard the corpus never had. A fixture that asked for `objects` and
// produced a fraction of them makes every arm built on it measure a store of
// a size nobody chose, under a name that says otherwise, and nothing else
// in the arm can tell, because duplicated ids are still found and still
// missed exactly as the assertions expect.
let distinct: std::collections::BTreeSet<&ObjectId> = ids.iter().collect();
ensure!(
distinct.len() == objects && ids.len() == objects,
"the fixture at {dir:?} holds {} ids of which {} are distinct, not the {objects} it is \
named for. Every rate measured against it would describe a store of the wrong size",
ids.len(),
distinct.len()
);
// ── the shape, measured off disk rather than restated ───────────────────
//
// The whole point of naming the shape is that it can now be WRONG, and say
// so. `objects_by_pack_residency` reads the `.idx` files, so this is the
// corpus as it is, not the request that produced it.
//
// Read off `pack_corpus_dir` and NOT off the store: on znippy the store has
// one container whatever the declared shape is, so `count_packs` there
// cannot tell a 1-pack fixture from a 16-pack one. The corpus keeps git's
// layout on both engines precisely so this census stays the same census.
let corpus = pack_corpus_dir(&dir);
let actual_packs = count_packs_in(&corpus);
ensure!(
actual_packs == packs,
"the fixture at {dir:?} ended with {actual_packs} pack(s) in {corpus:?}, not the \
{packs} its name claims — every arm built on it is measuring a different store"
);
let (_, duplicated) = objects_by_pack_residency(&corpus)?;
if overlap == 0 {
ensure!(
duplicated == 0,
"a fixture declared DISJOINT holds {duplicated} object(s) in more than one pack. \
That is the production shape, not this one, and `gunnar.lookup_1m_64pack`'s series \
is measured against the disjoint one"
);
} else {
ensure!(
duplicated > 0,
"a fixture asked for an overlap of {overlap} per pack holds NO object in more than \
one pack, so a multi-pack index over it has nothing to attribute and every arm \
built on it is blind to the defect it exists for"
);
}
let head = if served {
let hex = std::fs::read_to_string(&head_path)
.with_context(|| format!("reading {head_path:?} — a servable fixture with no tip"))?;
Some(ObjectId::from_hex(hex.trim().as_bytes())?)
} else {
None
};
// ── the ENGINE holds the corpus, asked of the engine ────────────────────
//
// Outside `cached_repo`, so it runs on the cheap path too: a fixture built
// by a version of this file that did not land the packs in the store is
// still on disk with its `.gunnar-bench-complete` marker written, and a
// guard that only ran during generation would never see it again.
//
// Three ids rather than one — first, middle, last — because the packs are
// landed in chunk order and a store that took the first `put_pack` and
// dropped the rest answers the first probe exactly like a healthy one.
// Three rather than all of them because the million-object corpus is the
// point of the cache.
//
// SEEN RED 2026-08-14, by removing the `store.git().put_pack(&buf)` above
// and letting the corpus go to disk only — which is exactly the state every
// znippy fixture root was in:
//
// the store at ".../packed-20000o-1p-v3" does not hold the first of its
// 20000 declared objects (217843ab…). The corpus is on disk in
// ".../packed-20000o-1p-v3/pack-corpus" and the engine has never been
// handed it — which is a fixture that probes as 0 hits out of every
// probe, reads as a store nobody wrote, …
//
// Fired on the cached path too (0.01 s, no rebuild), which is the half that
// matters: a guard that only ran during generation would never see a
// fixture already on disk with its completion marker written.
{
let store = gunnar_bench::engine::open_repo(&dir, HASH)
.with_context(|| format!("opening the packed fixture at {dir:?} to check it"))?;
let git = store.git();
for (label, id) in [
("the first", ids[0]),
("the middle", ids[ids.len() / 2]),
("the last", ids[ids.len() - 1]),
] {
ensure!(
git.has(id.as_slice())?,
"the store at {dir:?} does not hold {label} of its {objects} declared objects \
({}). The corpus is on disk in {corpus:?} and the engine has never been \
handed it — which is a fixture that probes as 0 hits out of every probe, \
reads as a store nobody wrote, and is what every znippy column of this file \
measured before 2026-08-14. Delete the fixture directory to rebuild it.",
id.to_hex()
);
}
}
Ok(PackedFixture {
dir,
ids,
head,
duplicated,
})
}
/// How many `.pack` files sit in **one named directory**.
///
/// The filesystem question, asked only where a filesystem answer is the right
/// one: a [`pack_corpus_dir`] this file wrote itself, or a clone stock `git`
/// produced. It is NOT the answer to "how big is this store" — see
/// [`count_packs`], which is what a repository must be asked.
fn count_packs_in(pack_dir: &Path) -> usize {
std::fs::read_dir(pack_dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.count()
})
.unwrap_or(0)
}
/// **How many pack containers a REPOSITORY's object store holds — on either
/// engine.**
///
/// # Root cause #2, and why the old body could only ever be right once
///
/// This was `read_dir(dir/objects/pack).count()` with an `unwrap_or(0)`, and it
/// has thirteen call sites, several of them hard guards spelled
/// `count_packs(&repo) > 0`. A znippy store has no `objects/` directory at all —
/// its bytes are one append-only `gunnar-git.znippy/objects.pack` — so on all
/// four znippy columns it returned **0 forever**, and `unwrap_or(0)` made a
/// directory that does not exist indistinguishable from a repository that is
/// empty. That is the wrapper failure exactly: 0 is not a small measurement, it is
/// the shape of an instrument pointed at the wrong place.
///
/// So the question goes to the store's own layout, through the one counter this
/// workspace already has for it: `gunnar_bench::store_bytes`, which detects the
/// shape rather than being told it, and **refuses** a directory whose shape it
/// does not recognise instead of returning a confident zero.
///
/// # `1` on znippy is the honest answer, not a placeholder
///
/// `StoreBytes::containers` is documented to be read literally: the znippy
/// engine appends every pack it is handed into one blob region, so a store
/// holding twelve pushes reads `containers = 1` with the payload of all twelve.
/// Counting the appended packs would mean parsing `objects.pack.journal`, a
/// znippy-internal format `bench/` has no business knowing. Every guard here
/// that reads this asks *"is there a store at all"* or *"did the store grow"*;
/// the first is answered correctly by 1, and the second is answered by
/// [`store_payload_bytes`], which moves on both engines.
///
/// # It returns `Result`, and that is half the fix
///
/// The old body ended in `.unwrap_or(0)`. Every caller therefore received a
/// number where an error belonged, and the two are not interchangeable in the
/// one direction that matters: `packs_last == packs_first` — `constellation`'s
/// read-only control — is *satisfied* by `0 == 0`, so an instrument that had
/// stopped working reported the control's whole purpose as achieved.
fn count_packs(dir: &Path) -> Result<usize> {
Ok(gunnar_bench::store_bytes::of(dir)?.containers as usize)
}
/// The verbatim pack payload a repository's store holds, in bytes — the number
/// that MOVES on both engines when objects land.
///
/// [`count_packs`] cannot answer "did this store grow": on znippy it is pinned
/// at 1 by construction. Any guard about growth has to read this instead.
fn store_payload_bytes(dir: &Path) -> Result<u64> {
Ok(gunnar_bench::store_bytes::of(dir)?.payload)
}
/// **Did the run pile up enough store to be the case it claims to measure?**
///
/// One helper and two callers — `gunnar.year` and `gunnar.rewrite` — because
/// each held its own copy of the same engine-blind floor (one implementation):
///
/// ```text
/// ensure!(packs_peak > 32.0, "… `gix_odb::Store` sizes its slot table ONCE …")
/// ```
///
/// **That floor is a fact about `gix_odb::Store`, not about a year.** It is
/// asking whether the run reached the 32-slot table a fresh gix repository
/// opens with. A znippy store has no slot table and no second pack: it appends
/// every pack it is handed into ONE `objects.pack`, so `packs` reads 1 for the
/// whole run — see [`count_packs`] — and `packs_peak > 32` is a floor that
/// engine can never clear, on any workload, however long. Both arms would have
/// gone red on all four znippy columns for a reason that says nothing about
/// either arm's subject.
///
/// So the floor is asked in the unit the store actually has:
///
/// * **git shape** — pack COUNT, peak above [`STORE_ACCUMULATION_FLOOR`],
/// exactly as before. The gix column keeps the assertion it had, word for
/// word, because the claim is true there.
/// * **any other shape** — the payload BYTES, which do move: the run must have
/// added at least [`STORE_ACCUMULATION_FLOOR`] **typical rounds' worth** of
/// payload beyond where it started.
///
/// # Why the byte floor is not a multiple of the starting size
///
/// It was: `last >= first * 1.5`. That is a ratio against the SEED, and the
/// seed is a real packed corpus this workload did not write — 4.05 MB of `knut`
/// on both callers. `gunnar.rewrite` moved it 4 054 926 -> 4 308 669 bytes in
/// fifty rounds (1.06x) and `gunnar.year` 4 048 611 -> 5 108 408 in fifty-two
/// weeks (1.26x), both measured on the desktop 2026-08-14 — so **both arms were red on
/// every znippy column for a floor that would have needed some four hundred
/// rounds against that corpus**, and nothing about either failure was a
/// statement about the engine, the workload or the arm.
///
/// The git branch never had that problem, because a pack count starts at 1
/// however big the seed is: `peak > 32` asks *did this workload's own pushes
/// pile up*. So the byte branch asks the same seed-independent question in the
/// unit it has — how much did this run ADD, in units of what one of its own
/// rounds adds. On a workload with uniform rounds the two coincide exactly: 33
/// rounds of equal growth reads as 32 steps, which is 33 packs on the other
/// shape. `STORE_ACCUMULATION_FLOOR` is therefore one constant serving both
/// branches rather than two numbers that have to be kept in agreement.
///
/// The step is the **median** round, not the first and not the mean. The mean
/// is degenerate — `added / mean` is the round count by definition and could
/// never fail — and one arbitrary round is the identity-value trap wearing a
/// denominator's clothes. The median also lets an accelerating run (the year's
/// user ramp) clear the floor by having genuinely accelerated, which is the
/// thing being claimed.
///
/// Returns what it measured — the pack peak, or the number of typical rounds'
/// worth the run accumulated — so the caller can put it on the row.
fn store_accumulated(
shape: gunnar_bench::store_bytes::StoreShape,
rows: &[Value],
knob: &str,
workload: &str,
) -> Result<f64> {
let n = |row: &Value, k: &str| row[k].as_f64().unwrap_or_default();
if shape == gunnar_bench::store_bytes::StoreShape::Git {
let series: Vec<f64> = rows.iter().map(|r| n(r, "packs")).collect();
let peak = series.iter().cloned().fold(0.0f64, f64::max);
ensure!(
peak > STORE_ACCUMULATION_FLOOR,
"the repository peaked at {peak} pack(s), round by round {series:?}. \
`gix_odb::Store` sizes its slot table ONCE at open and a fresh repository gets 32 \
slots, so a run that never passes {STORE_ACCUMULATION_FLOOR} cannot reach the case \
this arm is the worst for. This is a green that has not been earned; raise {knob}. \
{workload}"
);
return Ok(peak);
}
let series: Vec<f64> = rows.iter().map(|r| n(r, "store_payload_b")).collect();
let first = series.first().copied().unwrap_or(0.0);
let last = series.last().copied().unwrap_or(0.0);
ensure!(
first > 0.0,
"the first round read a store payload of {first} bytes, so there is nothing for the \
growth floor to be a multiple of and `the store grew` would be true of any number at \
all. {workload}"
);
let mut steps: Vec<f64> = series.windows(2).map(|w| w[1] - w[0]).collect();
ensure!(
!steps.is_empty(),
"the run produced one row, so there is no round-to-round growth to measure a floor in. \
{workload}"
);
steps.sort_by(|a, b| a.partial_cmp(b).expect("payload bytes are finite"));
let median = steps[steps.len() / 2];
ensure!(
median > 0.0,
"the median round of this run added {median} byte(s) to the store's payload, over \
{} round(s) {series:?}. A run whose typical round writes nothing has not accumulated \
anything, and dividing by it would make the floor below true of any number. {workload}",
steps.len()
);
let accumulated = (last - first) / median;
ensure!(
accumulated > STORE_ACCUMULATION_FLOOR,
"this store appends every pack into one blob region, so its container count is pinned \
at 1 and the pack-count floor cannot be asked of it. The floor is the payload instead, \
and NOT as a multiple of the seed corpus this workload did not write: the run added \
{} byte(s) on top of {first}, which is {accumulated:.1} typical round(s)' worth against \
a median round of {median} — floor {STORE_ACCUMULATION_FLOOR}. Round by round \
{series:?}. A run that barely moved the store has not reached the case this arm is the \
worst for; raise {knob}. {workload}",
last - first
);
Ok(accumulated)
}
/// **How much a run has to pile up before it is the case these arms claim to
/// measure** — 32, in whichever unit the store has one.
///
/// It is `gix_odb::Store`'s slot table: a fresh repository is opened with 32
/// slots sized ONCE, and the defect both callers exist to reach is what happens
/// on the thirty-third. On the git shape that is literally the pack count. On an
/// engine that appends every pack into one blob region it is 32 rounds' worth of
/// payload, which is the same workload measured in the only unit that moves.
/// See [`store_accumulated`] for why it is one constant and not two.
const STORE_ACCUMULATION_FLOOR: f64 = 32.0;
/// Wait until `objects/pack` has been quiet long enough that the store is
/// willing to trust its own scan of it.
///
/// `gunnar-store`'s pack-directory watch (`crate::odb`) will only skip
/// a re-scan when the mtime it memoised was already in the past when it took it.
/// A filesystem timestamp comes from a coarse clock, so a change inside the
/// same tick as the scan is invisible and the memo would be unsound. **A
/// repository that was written to a moment ago is therefore deliberately not in
/// the steady state**, and it is the steady state this arm is about: every
/// `have` and every `want` from every client, against a repository nobody is
/// pushing to at that instant.
///
/// Without this the arm would measure the racy window instead, and on a
/// first-ever run, when the fixture has just been generated, it would measure
/// nothing else. The "just after a write" number is reported separately by
/// [`Lookup1m64Pack`] rather than left implicit.
///
/// **Whichever directory this store keeps its bytes in.** `objects/pack` is
/// absent on the znippy arm, so the old body's `metadata()` failed, `age` was
/// `None`, and the loop ran its full sixty iterations — six seconds of sleeping
/// per call, on every znippy run, waiting for a directory that was never going
/// to appear.
fn settle(dir: &Path) {
let pack_dir = if dir.join("gunnar-git.znippy").is_dir() {
dir.join("gunnar-git.znippy")
} else {
dir.join("objects/pack")
};
for _ in 0..60 {
let age = std::fs::metadata(&pack_dir)
.and_then(|m| m.modified())
.ok()
.and_then(|m| std::time::SystemTime::now().duration_since(m).ok());
if age.is_some_and(|a| a.as_secs_f64() > 1.3) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
/// The probe set for one fixture: hits scattered across the whole id space, and
/// well-formed ids that are certainly absent.
fn probe_set(ids: &[ObjectId], probes: usize) -> (Vec<ObjectId>, Vec<ObjectId>) {
// A deterministic scatter, so the probe cannot sit inside one pack (or one
// page) and report a cache hit rate as a lookup rate.
let mut rng = Lcg::new(0x9E37_79B9);
let hits: Vec<ObjectId> = (0..probes)
.map(|_| ids[(rng.next_u64() % ids.len() as u64) as usize])
.collect();
(hits, miss_ids(ids, probes))
}
/// Run the hit and miss probes against an already-open store.
///
/// The store is the caller's because this arm now measures the **same**
/// repository in more than one configuration (with and without a multi-pack
/// index), and reopening between them would put a store-open (and the index
/// write that comes with it) inside the bracket.
fn probe_open(
store: &dyn gunnar_store::RepoStore,
hits: &[ObjectId],
misses: &[ObjectId],
) -> Result<(f64, f64, usize, usize)> {
let git = store.git();
// `GitOps::has` is the contract's single membership probe, and it is
// fallible where `ObjectStore::contains` was not. An error is NOT collapsed
// to `false` here: this arm's whole subject is the hit and miss rates, and a
// store that erred on every probe would otherwise publish a spectacular
// miss rate and a `found` of zero — a broken store reported as a fast one.
let has = |id: &ObjectId| -> Result<bool> { git.has(id.as_bytes()) };
// Warm both paths once, so the first probe does not pay for opening every
// `.idx`, or for the pack-directory scan that latches the staleness watch,
// and get it charged to the rate.
has(&hits[0])?;
has(&misses[0])?;
let start = Instant::now();
let mut found = 0usize;
for id in hits {
if has(id)? {
found += 1;
}
}
let hit_secs = start.elapsed().as_secs_f64();
let start = Instant::now();
let mut absent = 0usize;
for id in misses {
if !has(id)? {
absent += 1;
}
}
let miss_secs = start.elapsed().as_secs_f64();
Ok((
hits.len() as f64 / hit_secs,
misses.len() as f64 / miss_secs,
found,
absent,
))
}
/// Open a fixture, let it settle, and run the probes.
fn probe(dir: &Path, ids: &[ObjectId], probes: usize) -> Result<(f64, f64, usize, usize)> {
let store = gunnar_bench::engine::open_repo(dir, HASH)?;
settle(dir);
let (hits, misses) = probe_set(ids, probes);
probe_open(store.as_ref(), &hits, &misses)
}
/// 10k random oids against a million objects in **one** pack.
///
/// **Red condition: hit and miss are measured separately, and both must be
/// real.** O(packs) is a miss-path complexity, so a suite reporting one blended
/// `lookups_per_sec` cannot see it at all; that is the whole reason for two
/// metrics. `found` must equal the probe count and `absent` must equal the probe
/// count, both asserted. A "miss" probe that accidentally hits, or a store that
/// answers `false` to everything, would otherwise produce a beautiful number
/// describing nothing.
struct Lookup1m;
fn lookup_shape() -> (usize, usize) {
(
env_usize("GUNNAR_BENCH_LOOKUP_OBJECTS", scaled(1_000_000, 20_000)),
env_usize("GUNNAR_BENCH_LOOKUP_PROBES", scaled(10_000, 2_000)),
)
}
impl Bencher for Lookup1m {
fn id(&self) -> &'static str {
"gunnar.lookup_1m"
}
fn run(&self) -> Result<BenchResult> {
let (objects, probes) = lookup_shape();
let (dir, ids) = packed_fixture(1, objects)?;
let (hit_rate, miss_rate, found, absent) = probe(&dir, &ids, probes)?;
ensure!(
found == probes,
"only {found} of {probes} hit probes were found"
);
ensure!(
absent == probes,
"only {absent} of {probes} miss probes were absent"
);
Ok(result(
self.id(),
vec![
("lookups_per_sec_hit", json!(round(hit_rate, 1))),
("lookups_per_sec_miss", json!(round(miss_rate, 1))),
("objects", json!(objects as f64)),
("probes", json!(probes as f64)),
// The CORPUS's pack count, which is the shape the arm's name
// claims and is the same number on both engines. The store's
// own container count is reported beside it, because on znippy
// it is 1 for any corpus and a reader comparing the two columns
// has to be able to see that rather than infer it.
(
"packs",
json!(count_packs_in(&pack_corpus_dir(&dir)) as f64),
),
("store_containers", json!(count_packs(&dir)? as f64)),
("store_payload_b", json!(store_payload_bytes(&dir)? as f64)),
],
))
}
}
nornir_bench::register_bench!(Lookup1m);
// gunnar.multi_pack_serve: a clone served off a store that grew a multi-pack
// index by itself, judged by both receivers
// ───────────────────────────────────────────────────────────────────────────
/// Files in the multi-pack serve fixture, and pack rounds per commit.
///
/// Sixteen files touched three at a time over forty-eight commits, which is
/// `crates/gunnar-wire/tests/pack_copy.rs`'s `DUP_FILES` shape and is chosen for
/// the same measured reason: the corpus needs one blob to be a delta base inside
/// an early pack **and** to be spliced into a later one by `index-pack
/// --fix-thin`, which needs a file revised more than once inside one push round.
/// At sixty files that almost never happens; at sixteen it does, and this
/// fixture ends with 133 of 240 objects living in more than one pack.
const MIDX_FILES: usize = 16;
const MIDX_COMMITS: usize = 48;
const MIDX_LINES: usize = 400;
const MIDX_TOUCHED: usize = 3;
/// `git`'s spelling of [`HASH`], for `init` and for an `index-pack` run outside
/// any repository — where git assumes sha1 and would refuse a sha256 pack for a
/// reason that looks exactly like the defect this arm is about.
fn hash_arg() -> &'static str {
hash_arg_of(HASH)
}
/// [`hash_arg`] for a kind that is not [`HASH`].
///
/// Split out because `gunnar.clone_no_ofs_delta` runs on the **sha1**
/// head-to-head fixture while [`HASH`] is sha256, and handing `git index-pack`
/// the wrong `--object-format` makes it refuse a perfectly good pack with an
/// error that reads exactly like a corruption.
fn hash_arg_of(kind: Kind) -> &'static str {
match kind {
Kind::Sha1 => "--object-format=sha1",
_ => "--object-format=sha256",
}
}
/// A bare repository in the **production shape** — one pack per push, no
/// repack, no `.bitmap` — with enough packs that opening it writes a
/// `multi-pack-index`.
///
/// # This may not be built by cloning, and that is the whole point
///
/// `git clone --bare` **repacks**, so a corpus built that way is single-pack and
/// cannot hold one object in two packs. The other multi-pack fixture in this
/// file, [`packed_fixture`], writes `ids.chunks(per)` — **disjoint** packs, one
/// residency per object — and is only ever probed for lookups, never served.
/// Between them those two shapes are why `0d10e17`'s defect reached a customer
/// build: no arm here had ever served a clone off a store where an object lives
/// in two packs with an index over them.
///
/// Returns the bare repository and its tip.
fn midx_pushed_fixture(rounds: usize) -> Result<(PathBuf, ObjectId)> {
let name = format!(
"midx-pushed-{MIDX_FILES}f-{rounds}p-{MIDX_COMMITS}c-{hash}-2",
hash = hash_arg()
);
let dir = cached_repo(&name, |dir| {
let work = dir.join("seed");
let origin = dir.join("origin.git");
std::fs::create_dir_all(&work)?;
h2h_git(
&[
"init",
"-q",
"-b",
"main",
hash_arg(),
&work.to_string_lossy(),
],
None,
)?;
let at = |a: &[&str]| h2h_git(a, Some(&work)).map(|_| ());
at(&["config", "user.email", "bench@gunnar.invalid"])?;
at(&["config", "user.name", "gunnar bench"])?;
let mut content: Vec<Vec<String>> = (0..MIDX_FILES)
.map(|f| {
(0..MIDX_LINES)
.map(|i| format!("line {i:04} of file {f:03}, original text"))
.collect()
})
.collect();
for revision in 0..MIDX_COMMITS {
for slot in 0..MIDX_TOUCHED.min(MIDX_FILES) {
let f = (revision * MIDX_TOUCHED + slot) % MIDX_FILES;
// ~2% of the lines rewritten: a diff, not a rewrite. A whole new
// file every commit would delta to nothing, and git's search
// would leave no cross-pack base for the index to deduplicate.
for step in 0..MIDX_LINES / 50 {
let index = (revision * 7 + step * 53) % MIDX_LINES;
content[f][index] = format!("line {index:04} rewritten at revision {revision}");
}
let file = format!("churn-{f:03}.txt");
std::fs::write(work.join(&file), content[f].join("\n"))?;
at(&["add", &file])?;
}
at(&["commit", "-q", "-m", &format!("revision {revision}")])?;
}
h2h_git(
&[
"init",
"-q",
"-b",
"main",
"--bare",
hash_arg(),
&origin.to_string_lossy(),
],
None,
)?;
// Without this git unpacks a small push into loose objects and the
// multi-pack state this arm is about never arises.
h2h_git(&["config", "transfer.unpackLimit", "1"], Some(&origin))?;
for r in 1..=rounds {
let behind = MIDX_COMMITS - MIDX_COMMITS * r / rounds;
at(&[
"push",
"-q",
&origin.to_string_lossy(),
&format!("+HEAD~{behind}:refs/heads/main"),
])?;
}
// Deliberately no repack, no gc, no `--prune`: the packs ARE the fixture.
Ok(())
})?;
let origin = dir.join("origin.git");
let head = ObjectId::from_hex(
h2h_git(
&["-C", &origin.to_string_lossy(), "rev-parse", "HEAD"],
None,
)?
.trim()
.as_bytes(),
)?;
Ok((origin, head))
}
/// `(distinct objects, how many live in more than one pack)`, read out of the
/// v2 `.idx` files themselves.
///
/// First-party on purpose. `git verify-pack -v` and `git index-pack` both refuse
/// a duplicate at the door and print nothing, so a census taken through either
/// is an assertion that can never fail — and the v2 index is the one listing of
/// a pack's entries that survives the defect this arm exists for. The layout is
/// fixed and small: the magic `\xfftOc`, a version, a 256-cell fanout whose last
/// cell is the object count, then that many raw hashes.
///
/// Takes the **pack directory**, not the repository: the two callers keep their
/// indexes in different places — `midx_pushed_fixture`'s is a stock-git
/// repository's `objects/pack`, `packed_fixture_shaped`'s is its engine-neutral
/// [`pack_corpus_dir`] — and joining `objects/pack` in here silently counted
/// zero indexes for the second, which is a census that agrees with everything.
fn objects_by_pack_residency(pack_dir: &Path) -> Result<(usize, usize)> {
let mut per_id: std::collections::HashMap<Vec<u8>, usize> = std::collections::HashMap::new();
let mut indices: Vec<PathBuf> = std::fs::read_dir(pack_dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "idx"))
.collect();
indices.sort();
for idx in &indices {
for name in idx_object_ids(idx, HASH)? {
*per_id.entry(name).or_default() += 1;
}
}
Ok((per_id.len(), per_id.values().filter(|c| **c > 1).count()))
}
/// **Every object id a v2 `.idx` lists**, in the order the file stores them.
///
/// That order is ascending by id — it is what the fanout indexes into — so two
/// indexes over the same object set compare equal element by element, and a
/// `Vec` equality is a set equality without a sort or a hash.
///
/// The reader half of [`objects_by_pack_residency`], split out because a second
/// caller needs the ids themselves rather than a census of them (written once), and
/// first-party for the reason that function's doc already gives: `git
/// verify-pack -v` and `git index-pack` both refuse a duplicate at the door and
/// print nothing, so anything read *through* them cannot report one.
fn idx_object_ids(idx: &Path, hash: Kind) -> Result<Vec<Vec<u8>>> {
let width = hash.len_in_bytes();
let bytes = std::fs::read(idx).with_context(|| format!("reading pack index {idx:?}"))?;
ensure!(
bytes.starts_with(&[0xff, b't', b'O', b'c'])
&& u32::from_be_bytes(bytes[4..8].try_into()?) == 2,
"{idx:?} is not a v2 pack index, so this census would be reading the wrong format"
);
let fanout_end = 8 + 256 * 4;
let count = u32::from_be_bytes(bytes[fanout_end - 4..fanout_end].try_into()?) as usize;
let table = bytes
.get(fanout_end..fanout_end + count * width)
.with_context(|| {
format!("{idx:?} claims {count} objects but is too short to hold their ids")
})?;
Ok(table.chunks_exact(width).map(<[u8]>::to_vec).collect())
}
/// Total `.pack` bytes in a pack directory — git's own answer for how big this
/// history is, once `repack -adfq` has had its say.
///
/// The yardstick a server's clone is held against: it is produced by `git`, on
/// the same objects, and neither engine under test had any part in it.
fn pack_bytes_in(pack_dir: &Path) -> Result<u64> {
let mut total = 0u64;
for entry in std::fs::read_dir(pack_dir)
.with_context(|| format!("reading the fixture's pack directory {pack_dir:?}"))?
{
let path = entry?.path();
if path.extension().is_some_and(|x| x == "pack") {
total += std::fs::metadata(&path)?.len();
}
}
ensure!(
total > 0,
"{pack_dir:?} holds no .pack files, so there is nothing to size a served clone against"
);
Ok(total)
}
/// How a child process ended, in words, distinguishing a **refusal** from a
/// **crash**.
///
/// `ExitStatus::success()` is false for both, and the two mean opposite things
/// about the bytes we produced: a non-zero exit is the receiver's verdict on our
/// pack, a fatal signal is the receiver falling over and no verdict at all.
fn describe_exit(status: &std::process::ExitStatus) -> String {
use std::os::unix::process::ExitStatusExt as _;
match (status.code(), status.signal()) {
(Some(code), _) => format!("it exited {code}"),
(None, Some(sig)) => format!(
"IT WAS KILLED BY SIGNAL {sig} — the receiver crashed, which is not a verdict on \
this pack"
),
(None, None) => "it ended in neither an exit code nor a signal".to_string(),
}
}
/// **Hand `pack` to stock `git index-pack --strict` and return the object ids it
/// wrote down**, as hex.
///
/// The receiver that matters, and the only judgement in this file that neither
/// engine can influence: `git` is what a real client runs, so "git indexed it
/// and these are the objects" is the outcome, where "the server says it sent
/// 240" is a claim about the server.
///
/// Comparable against [`h2h_object_set`] without conversion — both are hex —
/// which is the whole reason this returns strings rather than the raw ids
/// [`idx_object_ids`] reads.
///
/// `--strict` because the loose check accepts a pack whose objects do not parse.
/// `tag` names the files inside `dir`, so one directory can judge several packs.
///
/// # It runs INSIDE a scratch repository, and that is not a detail
///
/// The obvious spelling is `git index-pack --strict --object-format=sha256`
/// outside any repository, and it is what stood here. MEASURED the desktop 2026-08-14,
/// **git 2.53.0**: that invocation **segfaults** — `Signal 11`, core dumped,
/// **empty stderr** — on a pack that is not connectivity-complete. It does it on
/// packs `git` itself wrote: `pack-03299ebb….pack` out of this file's own
/// `midx_pushed_fixture`, 25 entries, indexes fine without `--strict` and dies
/// with it.
///
/// An empty stderr behind a failed status is shaped exactly like a refusal, so
/// the arm reported *"stock git refused the pack this server produced:"*
/// followed by nothing — the "a malformed command fails silently and its
/// empty output is shaped exactly like a clean result", with the twist that here
/// it was shaped like somebody else's fault. Inside a `git init --bare
/// --object-format=…` scratch repository the same pack gets an honest verdict:
/// `fatal: did not receive expected object 04af66d4…`.
///
/// It is also the more faithful receiver: a real client indexes a fetched pack
/// into its repository, never into thin air.
fn objects_stock_git_accepts(
pack: &[u8],
dir: &Path,
tag: &str,
hash: Kind,
) -> Result<std::collections::BTreeSet<String>> {
let pack_path = dir.join(format!("{tag}.pack"));
let idx_path = dir.join(format!("{tag}.idx"));
let repo = dir.join(format!("{tag}.git"));
std::fs::write(&pack_path, pack).with_context(|| format!("writing {pack_path:?}"))?;
let _ = std::fs::remove_dir_all(&repo);
h2h_git(
&[
"init",
"-q",
"--bare",
hash_arg_of(hash),
&repo.to_string_lossy(),
],
None,
)
.context("creating the scratch repository a served pack is indexed into")?;
let out = Command::new("git")
.current_dir(&repo)
.args(["index-pack", "--strict", "-o"])
.arg(&idx_path)
.arg(&pack_path)
.output()
.context("running git index-pack over a served pack")?;
// **`success()` alone reads a CRASH as a refusal.** `git index-pack
// --strict --object-format=sha256` was seen dying on SIGSEGV here (the desktop,
// 2026-08-14, git 2.53), and a killed process has an empty stderr — so the
// refusal printed the server's byte count followed by nothing at all and
// read exactly like "git looked at your pack and disliked it silently".
// Naming the signal is the difference between a defect in what we serve and
// a defect in the receiver.
ensure!(
out.status.success(),
"stock git did not index the `{tag}` pack ({} bytes) this server produced — {}: {}",
pack.len(),
describe_exit(&out.status),
String::from_utf8_lossy(&out.stderr).trim()
);
idx_object_ids(&idx_path, hash)?
.into_iter()
.map(|raw| {
Ok(ObjectId::try_from(raw.as_slice())
.context("an id in a pack index is not this hash's width")?
.to_string())
})
.collect()
}
/// **A whole clone served off a store built by sixteen separate pushes, judged
/// by stock `git` against the object set the fixture actually reaches.**
///
/// This arm exists because its absence shipped a corrupt pack. Between
/// `fc2e0c3` (2026-08-04 20:54, the entry-length table) and `0d10e17`
/// (2026-08-05 11:47) every gunnar build served packs stock git refuses —
/// `fatal: pack is corrupted (SHA1 mismatch)` — out of any repository holding a
/// multi-pack index over packs that share an object, and no bench arm and no
/// test could see it.
///
/// # The mechanism, in one paragraph
///
/// `PackExtents` sizes an entry as the distance to the next offset in the same
/// pack, which is exact only if it holds every offset that pack has. A
/// multi-pack index keeps one **deduplicated** oid table, so an object in two
/// packs is recorded against exactly one of them and `gix-odb` builds the other
/// pack's list by filtering that table by pack — short by precisely the objects
/// the index attributed elsewhere. The entry before each hole is measured across
/// it, the copied entry carries its neighbour's bytes as a tail, and the
/// receiver parses every entry the header promised and then reads its trailer
/// from the wrong offset.
///
/// # RAISED 2026-08-14: the subject is the clone, not the index file
///
/// That paragraph describes **one engine's** storage. A `multi-pack-index` on
/// disk is how the gix arm holds sixteen packs; the znippy arm holds the same
/// history with no packfile at all, and asserting the file exists asked it a
/// question about somebody else's implementation — which is why this arm used to
/// fail on every znippy column with *"opening a 16-pack store wrote no
/// multi-pack-index"*, a true sentence about a store that was working fine.
///
/// What is worth measuring is one level up: **a store handed a history in
/// sixteen separate pushes still serves a correct clone.** So:
///
/// * the fixture is still built by sixteen real `git push`es, and the residency
/// census below is still taken — off the **source corpus**, which is a git
/// repository on every column, so it says the same thing everywhere;
/// * the corpus is then handed to whichever engine is selected, in that engine's
/// own layout, by [`engine_served`];
/// * and the clone that comes back is judged by **stock `git index-pack
/// --strict`** against `git rev-list --objects` over the fixture.
///
/// No longer asserted: that a `multi-pack-index` file exists, and that the
/// served entries arrive in any particular delta spelling. Both are mechanism.
/// The arm therefore no longer distinguishes the two engines' internals — that
/// is the point, and `storage-git-gix`'s own tests keep the entry-length
/// arithmetic honest beside the code that does it.
///
/// # Red conditions, and what each one catches
///
/// * **`objects_delivered` must equal the fixture's reachable set, id for id.**
/// The headline, and the only one that is about the product: stock git
/// indexed the served pack and these are the objects a client ends up with. A
/// server that drops, duplicates or mangles one fails here regardless of how
/// it stores anything.
/// * **stock git accepts it at all.** `fatal: pack is corrupted (SHA1
/// mismatch)` is what the original defect looked like from here.
/// * **`objects_in_two_packs > 0`.** Without a duplicate in the source corpus
/// the fixture is not the shape this arm exists for and every other assertion
/// is trivially true. It is the one that would have caught the absence.
/// * **`duplicate_oids_indexed == 0`, and the second receiver agreeing.**
/// gitoxide is kept as an independent parser — not as the subject — and this
/// is the exact failure the obvious repair to this arm produces; see below.
///
/// # The `GUNNAR_PACK_EXTENTS` A/B was removed 2026-08-14, because its switch
/// is not wired to anything
///
/// What stood here served the same store twice out of one handle, once with
/// `GUNNAR_PACK_EXTENTS=off`, and asserted the two packs were byte-identical.
/// That variable is read in exactly one place — `gunnar-pack/src/copy.rs`, by
/// `write_pack_copy` — and `write_pack_copy` has **no caller on any serve
/// path**; the only reference left in the constellation is one test in
/// `storage-git-gix`. The engine owns the format now and builds each pack's
/// entry-length table from that pack's own `.idx`, never from a multi-pack
/// index's deduplicated oid table, which is why `gunnar.multi_pack_extent_cost`
/// was tombstoned the same day for the same reason.
///
/// So the assertion was **the shipped path compared against itself**: two
/// serves that could not differ, reported as `surplus_bytes: 0` — a guard that
/// cannot fail, which is worse than no guard because it reads as
/// coverage. MEASURED the desktop 2026-08-14: with the fix in place both serves
/// produced 111 581 bytes, and with the emitter deliberately broken they
/// produced the *same wrong* bytes twice. The historical numbers it used to
/// carry are kept because they are real: 217 314 against 201 177 (+16 137, 8.0%
/// of the pack) on this fixture on 2026-08-06, and 46 080 of 8 066 803 (0.57%)
/// on `knut.git`.
///
/// # What replaced it, and why this one can fail
///
/// Both receivers now write an index, and this arm **reads both `.idx` files
/// itself** and holds them to each other and to the served object count. That
/// catches the defect the obvious repair here introduces: swapping `index_pack`
/// for `index_thin_pack(…, store)` makes gitoxide "succeed" and write **309
/// entries for a 240-object pack, 69 of them duplicate oids**, because
/// `LookupRefDeltaObjectsIter` resolves every ref-delta through the lookup with
/// no in-pack check. `IndexedPack::num_objects` alone does not see that — 309 is
/// a number, and the old `objects` assertion would simply have called it wrong
/// rather than saying what was wrong with it.
///
/// # What the row is worth as a measurement
///
/// `wire_bytes` is the cost of serving this store shape at all: 240 objects,
/// 132 of them resident in two of the sixteen packs.
struct MultiPackServe;
impl Bencher for MultiPackServe {
fn id(&self) -> &'static str {
"gunnar.multi_pack_serve"
}
fn run(&self) -> Result<BenchResult> {
let rounds = env_usize("GUNNAR_BENCH_MIDX_PACKS", MULTI_PACK_INDEX_THRESHOLD);
ensure!(
rounds >= MULTI_PACK_INDEX_THRESHOLD,
"{rounds} packs is below MULTI_PACK_INDEX_THRESHOLD ({MULTI_PACK_INDEX_THRESHOLD}), \
so no index would be written and this arm would serve the configuration it is not \
about"
);
let (origin, head) = midx_pushed_fixture(rounds)?;
// **The census is taken off the SOURCE corpus**, which is a repository
// stock `git push` built and is therefore the same on every column.
// Asking the store instead would answer 1 on znippy for any number of
// rounds, which is a true statement about a different question — and
// asking it for a `multi-pack-index` file, as this arm used to, is a
// question only one engine has an answer to at all.
let pack_dir = origin.join("objects/pack");
let packs = count_packs_in(&pack_dir);
ensure!(
packs == rounds,
"the fixture holds {packs} pack(s), not the {rounds} it was pushed in"
);
let (distinct, duplicated) = objects_by_pack_residency(&pack_dir)?;
ensure!(
duplicated > 0,
"no object of the {distinct} in this fixture lives in more than one pack, so a \
multi-pack index has nothing to choose between and this arm cannot see the defect \
it exists for"
);
// The truth the clone is judged against, read off the fixture by git
// before any engine has touched it.
let expected = h2h_object_set(&origin)?;
// Handed to whichever engine is selected, in that engine's own layout.
//
// **This is where the znippy column used to stop, and the red was a real
// finding rather than a fixture accident.** `git push` with
// `transfer.unpackLimit=1` runs `index-pack --fix-thin`, which completes
// each round's pack by APPENDING the bases it spliced in and leaving the
// deltas that name them as `OBJ_REF_DELTA` — so these packs carry
// ref-deltas whose base is right there in the same pack (verified 2026-08-14:
// base `96099daf…` is entry 1 of 25 in its own `.idx`).
//
// `znippy-plugin-git`'s ack path refused them. `pack_walk::closure()`
// does `DeltaBase::Ref(oid) => c.external_refs.push(oid)` with **no
// in-pack check at all** — and it cannot do otherwise, because a walk
// reports what the bytes say and a `REF_DELTA` names its base by oid,
// which is a fact about resolved content — and `external_bases_exist`
// then required every one of those to be in the store already. So this
// engine could not be handed a git repository's own packs.
//
// **Fixed 2026-08-14** (znippy `in-pack-ref-delta`): the fast path is
// unchanged and a base the store holds still settles with no bytes read,
// but a base that is *not* held now costs one `resolve_walked` over the
// pack before the push is refused, and the pack answering for itself is
// accepted. This arm is the guard for it, at 16 packs with
// `objects_in_two_packs > 0`.
//
// The **serving** half of the same shape was the second half of that
// fix and is what `receivers_accepting` below is really about: znippy's
// `emit_set` copied a stored `REF_DELTA` straight through when its base
// was in the request, and `gix_pack`'s `LookupRefDeltaObjectsIter` reads
// every ref-delta as naming something the *receiver* already holds — so
// stock git accepted the pack, gitoxide refused the whole of it, and
// `gunnar.rewrite` round 0 and `gunnar.year` week 0 died on all four
// znippy columns with `The object <oid> could not be decoded or wasn't
// found`. Such an entry is now re-headed as an `OFS_DELTA` naming the
// same base by distance, payload untouched. The third place with the
// missing in-pack check, `gix_pack`'s own iterator, is upstream
// (gitoxide#2882) and is why the rule here is the narrow one:
// never name a base by oid unless the receiver already holds it.
let served_dir = engine_served(&origin, &format!("midx-serve-{rounds}p")).context(
"handing this 16-pack corpus to the selected engine. If this failed with \"deltas \
against … which this repository does not have\", the pack is NOT thin — see the \
comment above this line",
)?;
let store = gunnar_bench::engine::open_repo(&served_dir, HASH)?;
let (served, objects) = gunnar_bench::stages::serve_on(store.as_ref(), HASH, &head, &[])?;
let pack_b = gunnar_bench::stages::demux_packfile(&served)?;
// ── the receiver that matters ───────────────────────────────────────
let judged = fixture_root().join("midx-serve-judged");
let _ = std::fs::remove_dir_all(&judged);
std::fs::create_dir_all(&judged)?;
let stock_ids =
objects_stock_git_accepts(&pack_b, &judged, "served", HASH).with_context(|| {
format!(
"a clone served off a {packs}-pack corpus ({duplicated} of {distinct} objects \
resident in two packs)"
)
})?;
ensure!(
stock_ids == expected,
"stock git indexed {} objects out of the served clone where `git rev-list --objects` \
says this fixture reaches {} ({} missing, {} unexpected) — the client would end up \
with the wrong repository",
stock_ids.len(),
expected.len(),
expected.difference(&stock_ids).count(),
stock_ids.difference(&expected).count()
);
let gix_dir = judged.join("gix");
std::fs::create_dir_all(&gix_dir)?;
// **`index_pack`, and it is deliberately NOT `index_thin_pack`.**
//
// MEASURED the desktop 2026-08-14 on the gix column, both ways, because the
// obvious repair here is wrong and looks right:
//
// * `index_thin_pack(…, GunnarObjects(store.git()), …)` "succeeds" and
// writes a **corrupt index**: 309 entries for a 240-object pack, of
// which **69 oids appear twice** (`git show-index | sort | uniq -d`).
// `gix_pack::data::input::LookupRefDeltaObjectsIter` resolves every
// ref-delta through the lookup and inserts the base as a new entry
// with **no in-pack check at all**, so handing it a store duplicates
// every base that was already in the pack. The id-list assertion
// below is what turns that from a plausible green into a red.
// * `index_pack` (no base lookup) is the honest receiver, and until
// 2026-08-14 it failed here — *"Ref delta objects are not supported
// as there is no way to look them up."* `gix_pack::index::write`
// refuses **every** `OBJ_REF_DELTA`, base in the pack or not, while
// stock git indexed the same 240 objects and exited 0.
//
// That red was true and it was not about the fixture: it named a missing
// capability in `storage-git-gix`'s `emit_oids`, which copied stored
// entries verbatim and so put `index-pack --fix-thin`'s ref-deltas
// straight on the wire. `emit_oids` now re-heads a delta into whichever
// spelling the client can read — ref-to-ofs here, ofs-to-ref in
// `gunnar.clone_no_ofs_delta`, one branch in `write_emission` — so both
// receivers see a pack of `OBJ_OFS_DELTA`s over an untouched payload.
let indexed = gunnar_pack::index_pack(
&mut std::io::BufReader::new(std::io::Cursor::new(&pack_b)),
Some(&gix_dir),
&IndexOptions::new(HASH),
None,
)
.context("gitoxide refused the same clone pack stock git accepted")?;
ensure!(
u64::from(indexed.num_objects) == objects,
"gitoxide indexed {} objects out of a pack the server says carries {objects}",
indexed.num_objects
);
// ── the second receiver's index, read rather than trusted ───────────
//
// `num_objects` above is the receiver's own count of its own work; these
// are the ids it actually wrote down. A v2 index stores them ascending,
// so a `Vec` equality is a set equality, and a duplicate shows up as a
// length that disagrees with its own dedup.
let gix_idx = indexed
.index_path
.clone()
.context("gitoxide indexed the pack but wrote no .idx to read back")?;
let gix_ids: std::collections::BTreeSet<String> = idx_object_ids(&gix_idx, HASH)?
.into_iter()
.map(|raw| {
Ok(ObjectId::try_from(raw.as_slice())
.context("an id in gitoxide's index is not this hash's width")?
.to_string())
})
.collect::<Result<_>>()?;
let duplicate_oids = indexed.num_objects as usize - gix_ids.len();
ensure!(
duplicate_oids == 0,
"gitoxide wrote an index of {} entries over this pack in which {duplicate_oids} \
oid(s) appear more than once — a base already in the pack was resolved through a \
store and inserted a second time, which is a corrupt index that `num_objects` alone \
calls merely wrong",
indexed.num_objects
);
ensure!(
gix_ids == stock_ids,
"the two receivers indexed the same {} pack bytes into different object sets — stock \
git listed {} ids and gitoxide {} — so at most one of them read the pack the server \
meant to send",
pack_b.len(),
stock_ids.len(),
gix_ids.len()
);
Ok(result(
self.id(),
vec![
("packs", json!(packs as f64)),
("objects", json!(objects as f64)),
("objects_in_two_packs", json!(duplicated as f64)),
("distinct_objects_on_disk", json!(distinct as f64)),
("objects_delivered", json!(stock_ids.len() as f64)),
("wire_bytes", json!(pack_b.len() as f64)),
("duplicate_oids_indexed", json!(duplicate_oids as f64)),
("receivers_accepting", json!(2.0)),
],
))
}
}
nornir_bench::register_bench!(MultiPackServe);
// ───────────────────────────────────────────────────────────────────────────
// gunnar.multi_pack_extent_cost: DELETED 2026-08-14 — its subject was retired
// ───────────────────────────────────────────────────────────────────────────
//
// The arm priced `gunnar_pack::copy`'s extent table: how much of a serve falls
// back to `gix-odb`'s inflate because `PackedObjects::learn_pack` declined a
// table for a pack a multi-pack index had left short. It reported
// `extent_lookups`, `extent_hit_frac`, `extent_declined_lookups` and a
// `GUNNAR_PACK_EXTENTS=off` A/B, and it is deleted rather than left red because
// **none of those five numbers has anything to read any more.**
//
// # What changed, in the tree's own words
//
// `crates/gunnar-wire/src/upload_pack.rs`, at the emission:
//
// > What stood here was gix: `write_pack_copy_thin` drove `iter_from_counts`
// > […] Both are gone with the packfile engine […] The engine owns the format
// > now.
//
// So no serve reaches `gunnar_pack::copy` on either engine. And the defect the
// arm priced cannot arise in the engine that replaced it:
// `storage-git-gix/src/packs.rs` builds each pack's entry-length table from
// **that pack's own `.idx`** ("a packfile's entries are contiguous, so the
// successor of an offset is where its entry ends"), never from the
// deduplicated multi-pack oid table. There is no short list, so there is no
// declined table, so there is no fallback to price.
//
// # Measured before deleting, rather than argued
//
// the desktop 2026-08-14, gix column, fixture `packed-40000o-16p-ov8-served-v2` built
// clean (16 packs, `multi-pack-index` written by the store's own policy, 120
// objects in two packs):
//
// ✗ gunnar.multi_pack_extent_cost: the served clone performed ZERO extent
// lookups, so neither the table nor the inflate was exercised and this row
// prices nothing
//
// That is the arm's own canary firing correctly, and it fires on every column
// and will keep firing: `gunnar_pack::extent_lookups()` cannot move when
// nothing on the serve path calls into `gunnar_pack::copy`. A row published
// from it would have carried `surplus_bytes: 0` and `receivers_accepting: 2`
// off an A/B whose switch is not wired to anything — the shipped path compared
// against itself, which is the failure its own doc comment named.
//
// # What still covers the ground it covered
//
// * **correctness of a served clone off a duplicated multi-pack store** —
// `gunnar.multi_pack_serve`, on a fixture built by sixteen real `git push`es
// rather than by writing the shape directly. It is the arm that judges with
// both receivers, and it kept its fixture.
// * **the entry-length arithmetic itself** — `storage-git-gix`'s own tests,
// beside the code that now does it.
//
// [`PackShape::served`] is kept: it is the only shape that produces a servable
// store with an object in two packs, and it is a fixture, not an arm.
// ───────────────────────────────────────────────────────────────────────────
// gunnar.negotiate_divergent: the classic git-server failure
// ───────────────────────────────────────────────────────────────────────────
/// `have`/`want` against a genuinely divergent merge history.
///
/// **Red condition: `rounds` must be greater than 1.** A `have`/`want` loop that
/// terminates on the happy path but not on a divergent one is the classic
/// git-server failure, and `gunnar-wire/src/negotiate.rs:4-10` says in its own
/// module docs that a three-commit fixture cannot see it: with a linear history
/// the client's very first `have` is common and the loop ends on round one. So a
/// one-round result here does not mean negotiation is fast, it means the fixture
/// is not divergent and the arm measured nothing. Asserted.
///
/// The fixture is a real divergence: a shared base, then `divergence` commits
/// down each of two branches. The client offers only its own branch's commits,
/// oldest last, 32 per round (git's window), so the server spends round after
/// round on `have`s it has never seen before, which is the shape that turns a
/// slow negotiation into an apparent hang.
///
/// `commits_visited` is the counting store's commit reads: the cost the walk
/// actually paid, not the number of rounds it took to pay it.
struct NegotiateDivergent;
impl Bencher for NegotiateDivergent {
fn id(&self) -> &'static str {
"gunnar.negotiate_divergent"
}
fn run(&self) -> Result<BenchResult> {
let base_len = env_usize("GUNNAR_BENCH_NEG_BASE", scaled(20_000, 500));
let divergence = env_usize("GUNNAR_BENCH_NEG_DIVERGENCE", scaled(20_000, 500));
let window = 32usize;
let store = MemGitServe::new(HASH);
let base = build_history(&store, HASH, 0x0BA5E, base_len, 1, 64, None)?;
// The server's branch.
let server = build_history(&store, HASH, 0x5E12, divergence, 1, 64, Some(base.tip))?;
// The client's branch: same base, entirely different commits. These live
// in the same store only because the bench has to construct them; the
// client offers them as `have`s and the server has genuinely never seen
// them as ancestors of anything it holds a reference to.
let client_store = MemGitServe::new(HASH);
let client = build_history(
&client_store,
HASH,
0xC11E,
divergence,
1,
64,
Some(base.tip),
)?;
store.put_refs(&[RefUpdate::set(
"refs/heads/main",
server.tip.to_hex().to_string(),
)])?;
// The client's `have`s, newest first, in the order git sends them: back
// down its own divergent branch, and then on into the shared history.
//
// The shared tail is load-bearing and was missing in the first version of
// this arm, which is why that version was wrong. Offering only the
// divergent commits produces a negotiation with NO common commit at all:
// `negotiate()` returns early on an empty `common` and never builds a
// `Reachability`, so `commits_visited` came back **0**: sixteen rounds of
// measuring nothing, reported as a passing arm. A client with a genuinely
// shared base does eventually offer it, and that is when the server starts
// paying: every subsequent round walks from the wants looking for a cut
// point. That is the cost this arm exists to measure.
let mut haves: Vec<ObjectId> = client
.ids
.iter()
.copied()
.filter(|id| is_commit(&client_store, id))
.collect();
haves.reverse();
//
// `GUNNAR_BENCH_NEG_NO_SHARED=1` omits the shared tail. That is not a
// tuning knob, it is this arm's **red switch**: it reproduces the broken
// fixture on demand, so `commits_visited = 0` can be seen failing rather
// than taken on trust from a comment. A guard nobody has watched fail is
// not known to work.
if std::env::var("GUNNAR_BENCH_NEG_NO_SHARED")
.map(|v| v != "0")
.unwrap_or(false)
{
eprintln!("bench: GUNNAR_BENCH_NEG_NO_SHARED=1, offering no common commit (red probe)");
} else {
let mut shared: Vec<ObjectId> = base
.ids
.iter()
.copied()
.filter(|id| is_commit(&store, id))
.collect();
shared.reverse();
haves.extend(shared);
}
let counting = Counting::new(&store);
let upload = UploadPack::new(&counting, HASH);
let mut rounds = 0usize;
let mut offered: Vec<ObjectId> = Vec::new();
let mut ready = false;
let mut common = 0usize;
let start = Instant::now();
for chunk in haves.chunks(window) {
offered.extend_from_slice(chunk);
let request = FetchRequest {
wants: vec![server.tip],
haves: offered.clone(),
done: false,
..Default::default()
};
let mut sink = Sink::default();
let outcome = upload.fetch(&request, &mut sink)?;
rounds += 1;
common = outcome.common.len();
if outcome.ready {
ready = true;
break;
}
}
let secs = start.elapsed().as_secs_f64();
ensure!(
rounds > 1,
"negotiation finished in one round; the fixture is not divergent, so this arm \
measured the happy path (negotiate.rs:4-10)"
);
ensure!(
!ready || common > 0,
"the server declared itself ready with no common commit"
);
// The assertion that caught this arm's own first version. `rounds > 1` is
// necessary but NOT sufficient: sixteen rounds against a client with no
// common commit never enter the reachability walk at all, and report a
// gorgeous sub-millisecond `negotiate_ms` for work that did not happen.
ensure!(
counting.commit_reads() > 0,
"commits_visited = 0 after {rounds} rounds. No `have` was ever common, so the \
server never walked for a cut point and negotiate_ms measures the empty case"
);
Ok(result(
self.id(),
vec![
("negotiate_ms", json!(round(secs * 1000.0, 2))),
("rounds", json!(rounds as f64)),
("commits_visited", json!(counting.commit_reads() as f64)),
("haves_offered", json!(offered.len() as f64)),
("common", json!(common as f64)),
("ready", json!(if ready { 1.0 } else { 0.0 })),
("divergence", json!(divergence as f64)),
],
))
}
}
nornir_bench::register_bench!(NegotiateDivergent);
fn is_commit(store: &dyn GitServe, id: &ObjectId) -> bool {
store
.header(id.as_bytes())
.ok()
.flatten()
.is_some_and(|(kind, _)| kind == ObjType::Commit)
}
// ───────────────────────────────────────────────────────────────────────────
// NOT BUILT: gunnar.clone_cold_tier
// ───────────────────────────────────────────────────────────────────────────
//
// The bench spec lists `gunnar.clone_cold_tier`, the same clone served entirely
// from the znippy cold tier, as the guard on whether that tier forecloses
// pack-copy and re-compresses every git object. **It cannot be written today**:
// there is no znippy cold-tier engine in this tree. `znippy` appears in gunnar
// only in two comments (`Cargo.toml:123` and `crates/gunnar-cli/Cargo.toml:41`,
// the latter saying the fs backend is "the only ObjectStore backend that
// survives a restart today"), and the cold tier is a design sketch, not code:
// there is no `ObjectStore` implementation over a znippy archive to clone from.
//
// Deliberately not stubbed. An arm that self-skips and reports green is the
// silent-green failure exactly, and it would let the cold-tier question be
// closed against a benchmark that never ran. When the znippy engine exists,
// this arm is a copy of `CloneLarge` with the store swapped and the same
// `objects_recompressed` assertion, which is the cold tier's whole question.
// ═══════════════════════════════════════════════════════════════════════════
//
// HEAD-TO-HEAD: gunnar versus stock `git`, serving the same repository
//
// ═══════════════════════════════════════════════════════════════════════════
//
// The eleven arms above measure gunnar against gunnar. They catch regressions
// and answer nothing about whether the project is worth continuing. Nothing here
// had ever been load-tested or profiled: "clone wall clock and pack size
// **against stock git on the same fixture**" was the missing half. This is it.
//
// # The shape of the comparison
//
// One client, one repository, one machine, both sides warm, and (this is the
// part that makes the ratios mean something) **the same transport**.
//
// ```text
// git clone "ext::<this binary> %S <repo>" <- the CLIENT is stock git,
// │ identically, on both sides
// └─ spawns: <this binary> git-upload-pack <repo> ← THE METER
// │ pumps the pipe both ways, counting bytes
// │ reaps the server with wait4 → CPU seconds, peak RSS
// └─ spawns the SERVER:
// side=git : git upload-pack <repo>
// side=gunnar : <this binary>, gunnar-wire in-process
// ```
//
// The meter is byte-for-byte the same process on both sides, so its cost (one
// extra process, two pumping threads, one memcpy of the stream) is present in
// both columns and cancels out of every ratio. `wait4` is what makes CPU
// seconds honest: on Linux it returns `RUSAGE_BOTH`, so `git upload-pack`'s
// fan-out into `git pack-objects` is counted. A server that wins wall clock by
// burning four cores loses `cpu_ratio_x`, and that is the number to read.
//
// # Isolation: a container, and both sides are inside it
//
// A container is used for exactly these arms and for exactly one
// reason: to make the stock-`git` toolchain's *presence and version*
// non-optional, because a self-skipping interop suite reports a false
// green. The image is `ubuntu:26.04` + `git=1:2.53.0-1ubuntu1`, which is the
// host's own distribution and the host's own git package, so:
//
// * the pinned `git` inside is the same build as `/usr/bin/git` outside, and
// * the glibc inside is the host's, which is what lets this binary, compiled
// on the host, run unchanged in there.
//
// **Both sides run inside one `podman run`.** Containerising only the stock-git
// arm would have added a namespace-and-cgroup asymmetry far larger than the
// version pinning it bought. The container is entered once per arm, outside the
// timed region, so it contributes nothing to either column.
//
// No KVM.
//
// # What is NOT equalised, stated rather than pretended away
//
// 1. **gunnar's SSH layer is not in the measurement.** `gunnar serve` is an SSH
// listener; it has no stdin/stdout `upload-pack` mode, so the gunnar side
// here is `gunnar-wire`'s `UploadPack`/`ReceivePack`, the same code the SSH
// server calls, hosted over a pipe. Either equalising the transports or
// stating the difference was acceptable; this equalises them, and the
// consequence is that **the numbers below are optimistic for gunnar**: they
// exclude key exchange, publickey auth, channel framing and the sshsig
// control-plane work a real `gunnar serve` also does. Stock git over `ssh://`
// would carry the same tax, which is why removing it from both sides is the
// defensible choice. But it is removed, and no number here should be quoted
// as "gunnar over SSH".
// 2. **The hash is sha1, and gunnar's default is sha256** (`D11`). The fixture
// is sha1 because that is what every repository anyone would migrate to
// gunnar actually is, and because a sha256 fixture would put *stock git* in
// the unusual configuration instead. `gunnar.vs_git_clone_sha256` measures
// the same clone in gunnar's native format so the size of that asymmetry is
// a number rather than a hand-wave.
// 3. **The gunnar server process is this bench binary**, so its resident set
// includes bench code stock `git-upload-pack` does not carry. Every clone arm
// therefore also serves a one-commit repository and reports
// `*_rss_floor_mb`; the workload's own memory is the difference, and the
// floor is what should be subtracted before the RSS columns are compared.
// 4. **The host is shared.** the desktop runs other concurrent jobs. Every arm interleaves
// `gunnar, git, gunnar, git, …` and reports the median, and the clone arm
// emits `gunnar_drift_x` (first gunnar round over last gunnar round), so a
// reader can see how much the host moved underneath the comparison.
/// The pinned toolchain image. Built from `Containerfile`:
/// `FROM docker.io/library/ubuntu:26.04` + `git=1:2.53.0-1ubuntu1`.
const H2H_IMAGE: &str = "localhost/gunnar-bench-git:2.53.0";
/// The `git --version` every head-to-head arm asserts it measured against.
/// A version that does not match is **red**, not a footnote: a pack-size ratio
/// is a statement about a specific `git`, and a suite that self-skips and reports
/// green is what happens when a toolchain's identity is assumed, not checked.
const H2H_GIT_VERSION: &str = "git version 2.53.0";
fn h2h_image() -> String {
std::env::var("GUNNAR_BENCH_H2H_IMAGE").unwrap_or_else(|_| H2H_IMAGE.to_string())
}
/// The scratch tree the driver clones into. Under the fixture root, i.e. md1.
fn h2h_work() -> PathBuf {
fixture_root().join("h2h-work")
}
// ── SEAM 1: which backend serves gunnar's `ext::` column ───────────────────
//
// `GUNNAR_BENCH_GUNNAR_SERVER=<path>` is the *executable* that serves the gunnar
// side, spawned with the identical argv git's `ext::` transport uses
// (`[exe, <service>, <repo>]`) and the identical `GUNNAR_BENCH_COPY_STATS`
// contract. **It is mandatory.** There was a fallback — unset, the meter
// re-executed this binary and served out of a store it built in-process — and
// it is deleted; see `h2h_serve_gunnar`.
//
// **Why a process and not a trait object.** The obvious seam would be a
// `--backend znippy` switch constructing a different store inside this binary.
// It cannot be: an engine crate in `[dependencies]` drags its own world in at
// cargo **resolve** time (znippy needs a C toolchain and cmake), so naming one
// here makes a sibling checkout a hard requirement of running *any* arm —
// including the 40-odd with nothing to do with storage. So the seam goes where
// the store was already chosen: in these arms, a process boundary that exists.
//
// **bench/ therefore stays engine-free: it knows a path, not a crate.**
//
// 🔴 That sentence stood here from the day the seam was written and was **false
// as written until 2026-08-10**. `bench/Cargo.toml` carried a plain,
// non-optional the old znippy engine crate path dependency, and the old znippy engine crate
// path-depends on four znippy crates — so a znippy checkout *was* a hard
// requirement of building all 49 arms, which is the exact cost the paragraph
// above says the design avoids. Two surveys read this comment and neither
// checked the manifest against it. It is true now: `grep -c znippy engine
// bench/Cargo.toml` is 0, and the engine reaches a measurement only as a path.
//
// ⚠️ **THERE ARE TWO SEAMS AND THIS IS ONLY THE FIRST.** The eight `vs_forge_*`
// arms take `GUNNAR_BENCH_FORGE_BINARY` — a whole server binary bind-mounted
// into a podman container and started with `serve --http-bind` (`forge/mod.rs`,
// `gunnar_binary()`) — and **ignore this variable entirely**. A driver that sets
// only this one and runs with a `gunnar.` filter runs both columns of those
// eight arms against the same binary and labels them as two engines. See
// `gunnar_bench::engine` for the pair written down together.
fn h2h_gunnar_server() -> Option<PathBuf> {
gunnar_bench::engine::server_binary()
}
/// Build the alternate backend's store for `repo`, **outside the timed region**.
///
/// A no-op when no alternate server is configured, so every call site reads the
/// same on both backends. The helper is the one that knows what a store is; this
/// side only knows that setup has to happen before the clock starts, which is
/// the part a benchmark can get wrong.
fn h2h_prepare_gunnar(repo: &Path) -> Result<()> {
let Some(bin) = h2h_gunnar_server() else {
return Ok(());
};
let out = Command::new(&bin)
.arg("prepare")
.arg(repo)
// PIPED, not inherited, because the engine read-back below is parsed out
// of it. It is echoed on to stderr immediately afterwards so the line an
// operator is used to seeing is still in the log.
.stderr(Stdio::piped())
.output()
.with_context(|| format!("running {} prepare {}", bin.display(), repo.display()))?;
let err = String::from_utf8_lossy(&out.stderr).into_owned();
eprint!("{err}");
ensure!(
out.status.success(),
"{} prepare {} exited {:?}: {}",
bin.display(),
repo.display(),
out.status.code(),
err.trim()
);
h2h_assert_engine(&err, repo)
}
/// Where seam 1's `prepare` puts the store it builds for `repo`.
///
/// `git-store-serve`'s own `store_root`: `<repo>.gunnar-<engine>`, the same
/// string [`h2h_assert_engine`] rebuilds to corroborate the engine read-back.
/// Named once here because [`h2h_serve_daemon`] has to *open* that directory
/// rather than merely check it exists, and two spellings of one convention is
/// how the seam and the store come to disagree.
///
/// The engine defaults to `znippy` when `GUNNAR_ENGINE` is unset, matching
/// `gunnar_server::storage::Engine::selected()` and [`h2h_assert_engine`]'s
/// `want`.
fn h2h_engine_store_root(repo: &Path) -> PathBuf {
let engine = std::env::var("GUNNAR_ENGINE")
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "znippy".to_string());
PathBuf::from(format!("{}.gunnar-{engine}", repo.display()))
}
/// **The engine seam-1's server says it used**, read back and refused on
/// mismatch.
///
/// The seam-2 counterpart is `forge/parity.rs`'s `assert_storage_engine`, and
/// this exists for the same reason and after the same failure: what the harness
/// passed is not what the server ran. Here the evidence is `git-store-serve`'s
/// own `prepare` report — it opens with `<engine>-prepare:` on **both** its
/// paths (the already-populated one and the ingest one) — corroborated by the
/// store directory it wrote, which is `<repo>.gunnar-<engine>` and so names the
/// engine in the filesystem as well.
///
/// Two facts, not one, deliberately: the line is a string the server prints and
/// the directory is a side effect it cannot fake, and this arm has already been
/// burned once by an environment variable that was set and never arrived.
///
/// Seen red: before `h2h_run` forwarded `GUNNAR_ENGINE` across the container
/// boundary, a `GUNNAR_ENGINE=gix` run reported `znippy-prepare:` and wrote
/// `<repo>.gunnar-znippy`, and this function is what says so out loud instead of
/// publishing that column as gix.
///
/// # What this does NOT cover, and what would
///
/// Only the `clone` and `fetch` scenarios call `prepare`, so this guard reaches
/// `vs_git_clone`, `vs_git_clone_sha256` and `vs_git_fetch_divergent`. The
/// `push` and `concurrent` scenarios build their store by pushing into it and
/// never call `prepare`, so **`vs_git_push` has no programmatic engine
/// read-back**.
///
/// Checking that `<repo>.gunnar-<want>` merely *exists* would not fix that; it
/// would be a hollow guard. The fixtures are reused across passes, so after one
/// znippy pass and one gix pass BOTH directories are there and an existence
/// check passes whichever engine actually served.
///
/// The evidence for those arms does exist — `git-store-serve`'s serve path
/// prints `<engine>: served git-upload-pack …` / `git-receive-pack` per session,
/// from the process that served — but it goes to inherited stderr, so it lands
/// in the run's log rather than anywhere an assertion can reach. Closing it
/// properly means the meter capturing its child's stderr and writing the engine
/// beside the receipt it already writes for `GUNNAR_BENCH_COPY_STATS`. Not done
/// here; stated so it is not mistaken for covered.
fn h2h_assert_engine(prepare_stderr: &str, repo: &Path) -> Result<()> {
let want = std::env::var("GUNNAR_ENGINE")
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "znippy".to_string());
let said = prepare_stderr
.lines()
.find_map(|l| l.trim().split("-prepare:").next().filter(|n| !n.is_empty()))
.map(|n| n.trim().to_ascii_lowercase())
.with_context(|| {
format!(
"`git-store-serve prepare {}` printed no `<engine>-prepare:` line, so this run \
cannot say which storage engine built the store it is about to measure. \
Refusing rather than labelling the column with the engine that was REQUESTED.\n\
What it did print:\n{}",
repo.display(),
prepare_stderr.trim()
)
})?;
ensure!(
said == want,
"seam 1's server prepared {} with the {said:?} storage engine and this run asked for \
{want:?}. `GUNNAR_ENGINE` is read inside the head-to-head DRIVER's container, and the \
driver's environment is an explicit allow-list — a variable that is set on the host and \
never crosses is how this arm served znippy for both columns of a two-engine run.",
repo.display()
);
// The corroborating fact: `store_root` writes `<repo>.gunnar-<engine>`, so
// the engine is in the filesystem and not only in a string the server chose
// to print.
let root = PathBuf::from(format!("{}.gunnar-{said}", repo.display()));
ensure!(
root.exists(),
"seam 1's server reported {said:?} but wrote no store at {}. The report and the \
directory disagree, so one of them is not describing this run.",
root.display()
);
Ok(())
}
// ───────────────────────────────────────────────────────────────────────────
// THE METER: `GUNNAR_BENCH_SERVE=<side>`, run by git's `ext::` transport
// ───────────────────────────────────────────────────────────────────────────
/// Spawn the real server, pump the pipe, and report what it cost.
///
/// `argv` is what `ext::` hands a helper: `[exe, <service>, <repo>]`, where
/// `<service>` is `git-upload-pack` or `git-receive-pack` (`%S`).
///
/// The pumping is deliberate rather than an accident of implementation. `exec`ing
/// the server would be one process cheaper, but then nothing counts the bytes and
/// nothing holds the `rusage`; and counting them on only one side would be worse
/// than not counting them at all.
fn h2h_serve_meter(side: &str) -> Result<()> {
// The label this pass is recorded under. Normally the side itself; the
// `GUNNAR_BENCH_H2H_SAME_SIDE` red switch sets them apart deliberately, so
// that a run where BOTH columns are gunnar still reaches the arm's
// comparison guards instead of being caught by the meter's own side check.
let label = std::env::var("GUNNAR_BENCH_SERVE_LABEL").unwrap_or_else(|_| side.to_string());
let args: Vec<String> = std::env::args().collect();
let service = args.get(1).cloned().unwrap_or_default();
let repo = args.get(2).cloned().unwrap_or_default();
ensure!(
service == "git-upload-pack" || service == "git-receive-pack",
"the ext:: helper was asked for service {service:?}, which is not a git service"
);
// The `gunnar-daemon` side is not a child at all: gunnar's `FsStore` takes a
// cross-process exclusive lock on the repository (`gunnar-store.lock`), so a
// second gunnar process cannot open a repository the first one has. That is a
// property of what ships, not of this harness, and it is why the concurrent
// arm runs each side in ITS OWN native shape. See `VsGitCloneConcurrent`.
// Here the meter simply proxies its pipe to the one long-lived gunnar
// process, and that process is measured from `/proc` by the driver.
if side == "gunnar-daemon" {
return h2h_meter_daemon(&service);
}
// Where the gunnar server will leave its copy-versus-recompress split. One
// file per session, named off this meter's own pid so sixteen concurrent
// meters cannot land in each other's.
let copy_stats: Option<PathBuf> = std::env::var_os("GUNNAR_BENCH_STATS")
.map(|s| PathBuf::from(s).with_extension(format!("copy.{}", std::process::id())));
if let Some(path) = ©_stats {
let _ = std::fs::remove_file(path);
}
let mut command = match side {
"git" => {
let verb = service.strip_prefix("git-").unwrap_or(&service).to_string();
let mut c = Command::new("git");
c.arg(verb).arg(&repo);
c
}
"gunnar" => {
// ── THE STORE SEAM, and it is no longer optional ────────────────
//
// There used to be a `None` arm here that re-executed this binary
// with `GUNNAR_BENCH_SERVE_INNER=1` and served out of a store built
// in-process. It is deleted, and deleting it is what turns the seam
// comment above from an aspiration into a fact: the *only* way an
// engine reaches a measurement now is as an executable on a path.
//
// Refusing is the safe direction. The alternative — falling back to
// some other store when the configured one is missing — publishes a
// full ratio table for an engine that never ran, under that engine's
// label. That is the same failure `h2h_serve_daemon` asserts against
// and the same one `two-engine.sh` refuses to start into.
let mut c = Command::new(gunnar_bench::engine::require_server()?);
if let Some(path) = ©_stats {
c.env("GUNNAR_BENCH_COPY_STATS", path);
}
c.arg(&service).arg(&repo);
c
}
other => bail!("unknown head-to-head side {other:?} (expected `git` or `gunnar`)"),
};
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let started = Instant::now();
let mut child = command
.spawn()
.with_context(|| format!("spawning the {side} server"))?;
let pid = child.id() as i32;
let mut to_server = child.stdin.take().context("no server stdin")?;
let mut from_server = child.stdout.take().context("no server stdout")?;
// client → server. Counted, and (for upload-pack only, where the inbound
// stream is small and plaintext pkt-line) scanned for `have ` so that
// negotiation volume is measured the SAME way on both sides. gunnar can
// report its own round count; `git upload-pack` cannot, so a metric derived
// from one side's self-report would not be a comparison.
let bytes_in = Arc::new(AtomicU64::new(0));
let haves = Arc::new(AtomicU64::new(0));
let count_haves = service == "git-upload-pack";
{
let bytes_in = Arc::clone(&bytes_in);
let haves = Arc::clone(&haves);
std::thread::spawn(move || {
let stdin = std::io::stdin();
let mut stdin = stdin.lock();
let mut buf = vec![0u8; 64 * 1024];
let mut carry: Vec<u8> = Vec::new();
loop {
let n = match stdin.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
if to_server.write_all(&buf[..n]).is_err() || to_server.flush().is_err() {
break;
}
bytes_in.fetch_add(n as u64, Ordering::Relaxed);
if count_haves {
haves.fetch_add(
h2h_count(b"have ", &buf[..n], &mut carry),
The file goes on; the console shows all of it.