crates/storage-git-gix/parked-gix-2/bench-gix/gix-census.rs
//! **What does gunnar ask gix for, and what does it cost?**
//!
//! The measuring half of `.nornir/gix-census-reasoning.md`. A diagnostic, not a
//! persisted row — it answers *how often* and *where the cycles are*, and
//! `nornir bench run` is for *how long*.
//!
//! Four subcommands:
//!
//! ```text
//! # how often, per request, per command, over the whole corpus
//! CORPUS=.../real-corpus gix-census counts [depth]
//!
//! # a long, steady fetch to hang `perf record` on, with the bitmap ASSERTED
//! gix-census loopfetch <repo.git> <iters> <want> [have ...]
//!
//! # the same for a push, so `index_thin_pack` can be broken down by symbol
//! gix-census looppush <source.git> <workdir> <iters> <tip>
//!
//! # what an entry's on-disk length costs to learn, both ways, proven equal first
//! gix-census entrysize <repo.git> [sample]
//! ```
//!
//! # Why a loop and not one request
//!
//! One negotiated fetch out of the largest corpus repository is ~26 ms of CPU.
//! At `perf record -F 4999` that is ~130 samples spread over ~200 symbols, and
//! the resulting histogram is noise with a shape. `loopfetch` serves the same
//! request `iters` times **out of one already-open store**, which is also the
//! production shape: a daemon opens a repository once and answers out of it for
//! weeks, so the `.idx` mmap, the `.bitmap` parse and the pack-entry extent
//! table are paid once rather than per request.
//!
//! The loop calls [`stages::serve_on`], not `wire_digest_on`, and takes the
//! digest on the first and last iteration only. The FNV pass is byte-at-a-time
//! over a 6.59 MB response — ~8% of the first profile this lane took, all of it
//! harness, sitting in the denominator of every share it reported.
//!
//! # The bitmap assertion, which is the point of `loopfetch`
//!
//! A `perf` profile of a fetch reported 54% of cycles in zlib, and the
//! hypothesis on record was that it had been taken with reachability bitmaps
//! **off** — three wrappers in this tree have silently disabled the bitmap by
//! not forwarding `ObjectStore::bitmap`, whose trait default is `None`.
//!
//! **That hypothesis is refuted, by this binary.** With the bitmap path
//! asserted, zlib was 74.6% of a negotiated fetch, and the cause was
//! `gix_odb::location_by_oid` inflating every entry to learn its length —
//! landed since as `PackedObjects`'s extent table, and re-measured here at
//! **1.619×** through the `GUNNAR_PACK_EXTENTS` A/B. The assertion stays
//! because it is what made that answer trustworthy: it is
//! [`StageRun::expect_path`], and `GUNNAR_BITMAP=off` drives it red.
//!
//! See `.nornir/gix-census-reasoning.md` for the whole census.
use anyhow::{ensure, Context, Result};
use gix_hash::ObjectId;
use gunnar_bench::stages;
use gunnar_pack::oidprobe;
use gunnar_store::fs::FsStore;
fn oid(text: &str) -> Result<ObjectId> {
ObjectId::from_hex(text.trim().as_bytes()).with_context(|| format!("{text:?} is not an oid"))
}
fn git(args: &[&str]) -> Result<String> {
let out = std::process::Command::new("git").args(args).output()?;
ensure!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
Ok(String::from_utf8(out.stdout)?)
}
/// The tip of the first branch and the commit `depth` back along its
/// first-parent chain. Same rule as `oidlookup-probe`, so the two probes
/// describe the same requests.
fn tips(dir: &str, depth: usize) -> Result<(ObjectId, Option<ObjectId>)> {
let heads = git(&[
"-C",
dir,
"for-each-ref",
"--format=%(objectname)",
"refs/heads/",
])?;
let tip = heads
.lines()
.next()
.context("no branches")?
.trim()
.to_owned();
let n: usize = git(&["-C", dir, "rev-list", "--first-parent", "--count", &tip])?
.trim()
.parse()?;
let back = depth.min(n.saturating_sub(1));
let older = if back == 0 {
None
} else {
Some(oid(&git(&[
"-C",
dir,
"rev-parse",
&format!("{tip}~{back}"),
])?)?)
};
Ok((oid(&tip)?, older))
}
/// Two partitions of the same entries must each add up to the objects served.
///
/// **The guard the kind counters shipped without, and it cost a run.** A
/// `bump` whose call site never landed in the file reads as a clean zero, and
/// `GIX_PACK_KIND_BASE 0 (0.000/request)` is shaped exactly like "this fetch
/// emitted no base entries" — which is a plausible sentence. All three read
/// zero for two runs before anyone looked at the diff.
///
/// So the census asserts the arithmetic rather than the presence of a number:
/// `base + delta_ref + delta_oid` and `copied + recompressed` are two
/// independent partitions of the entries written, and both must equal the
/// objects served. A missing bump breaks the first; a miscounted veto breaks
/// the second.
fn check_entry_partitions(snap: &[(&'static str, u64)], objects: u64) -> Result<()> {
let get = |n: &str| {
snap.iter()
.find(|(k, _)| *k == n)
.map(|(_, v)| *v)
.unwrap_or(0)
};
let kinds =
get("GIX_PACK_KIND_BASE") + get("GIX_PACK_KIND_DELTA_REF") + get("GIX_PACK_KIND_DELTA_OID");
let split = get("GIX_PACK_COPIED") + get("GIX_PACK_RECOMPRESSED");
ensure!(
kinds == objects,
"the three entry-kind counters total {kinds} against {objects} objects served. A counter \
whose call site is not in the binary reads as a measured zero"
);
ensure!(
split == objects,
"copied + recompressed is {split} against {objects} objects served"
);
Ok(())
}
// ── counts ──────────────────────────────────────────────────────────────────
/// Every gix entry point gunnar reaches, counted per command, over the corpus.
///
/// Four commands, not one. `ls-remote` never opens a pack, `clone` and `fetch`
/// differ by a factor of two in resolutions per object, and `push` runs a
/// completely different set of gix calls — a census taken on `fetch` alone
/// would report three of the four columns as zero and they would look measured.
fn counts(depth: usize) -> Result<()> {
let root = std::path::PathBuf::from(
std::env::var("CORPUS")
.unwrap_or_else(|_| "/home/rickard/scratch/gunnar-bench-fixtures/real-corpus".into()),
);
let mut repos: Vec<std::path::PathBuf> = std::fs::read_dir(&root)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "git"))
.collect();
repos.sort();
ensure!(
repos.len() >= 12,
"the corpus holds {} repositories; a per-request count averaged over a corpus that did \
not get built is a count of nothing",
repos.len()
);
let names: Vec<&'static str> = oidprobe::snapshot().iter().map(|(n, _)| *n).collect();
ensure!(
!names.is_empty(),
"gunnar-pack was built without the `oidprobe` feature, so every count below would be a \
column of nothing"
);
let work = root.parent().unwrap_or(&root).join("gix-census-work");
let _ = std::fs::remove_dir_all(&work);
std::fs::create_dir_all(&work)?;
println!(
"repo,mode,objects,wire_bytes,proc_cpu_us,{}",
names.join(",")
);
// mode -> (objects, counts, process cpu ns, requests)
let mut totals: std::collections::BTreeMap<&str, (u64, Vec<u64>, u64, u64)> =
Default::default();
let note =
|mode: &'static str,
objects: u64,
wire: u64,
cpu_ns: u64,
name: &str,
totals: &mut std::collections::BTreeMap<&str, (u64, Vec<u64>, u64, u64)>| {
let snap = oidprobe::snapshot();
println!(
"{name},{mode},{objects},{wire},{},{}",
cpu_ns / 1000,
snap.iter()
.map(|(_, v)| v.to_string())
.collect::<Vec<_>>()
.join(",")
);
let e = totals
.entry(mode)
.or_insert_with(|| (0, vec![0; snap.len()], 0, 0));
e.0 += objects;
e.2 += cpu_ns;
e.3 += 1;
for (slot, (_, v)) in e.1.iter_mut().zip(snap.iter()) {
*slot += v;
}
};
for repo in &repos {
let d = repo.to_string_lossy().into_owned();
let name = repo.file_stem().unwrap().to_string_lossy().into_owned();
let (tip, older) = tips(&d, depth)?;
// ls-remote: the advertisement and nothing else.
oidprobe::reset();
let ls = stages::ls_remote(repo)?;
note(
"ls-remote",
ls.objects,
ls.wire_bytes,
ls.process_cpu_ns,
&name,
&mut totals,
);
oidprobe::reset();
let run = stages::fetch(repo, &tip, &[])?;
note(
"clone",
run.objects,
run.wire_bytes,
run.process_cpu_ns,
&name,
&mut totals,
);
if let Some(have) = older {
oidprobe::reset();
let run = stages::fetch(repo, &tip, &[have])?;
note(
"fetch",
run.objects,
run.wire_bytes,
run.process_cpu_ns,
&name,
&mut totals,
);
}
// push: the pack a real upload-pack produced, indexed into a fresh bare
// repository. `stages::push` builds both halves.
let target = work.join(format!("{name}.git"));
let _ = std::fs::remove_dir_all(&target);
oidprobe::reset();
let run = stages::push(repo, &target, &tip, "refs/heads/censused")?;
note(
"push",
run.objects,
run.wire_bytes,
run.process_cpu_ns,
&name,
&mut totals,
);
let _ = std::fs::remove_dir_all(&target);
}
println!();
println!(
"mode,requests,objects,proc_cpu_ms,{},per_object_total",
names.join(",")
);
for (mode, (objects, counts, cpu_ns, requests)) in &totals {
// `entry_by_location` is keyed by OFFSET and does no oid search, so it
// is counted but kept out of the oid-lookup total.
let by_offset = names
.iter()
.position(|n| *n == "GIX_PACK_ENTRY_BY_LOCATION");
let total: u64 = counts
.iter()
.enumerate()
.filter(|(i, _)| Some(*i) != by_offset)
.map(|(_, v)| v)
.sum();
println!(
"{mode},{requests},{objects},{:.3},{},{:.3}",
*cpu_ns as f64 / 1e6,
counts
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(","),
total as f64 / (*objects).max(1) as f64
);
}
let _ = std::fs::remove_dir_all(&work);
Ok(())
}
// ── the profiling loops ─────────────────────────────────────────────────────
fn loopfetch(
repo: &std::path::Path,
iters: usize,
want: &ObjectId,
haves: &[ObjectId],
) -> Result<()> {
let kind = gunnar_store::fs::stored_hash_kind(repo)?;
// One instrumented request FIRST, so the loop's selection path is a fact
// rather than an assumption. Recording is off for the loop itself.
//
// And strictly before the loop's own `FsStore::open_or_init`: that call
// takes a writer lock on the repository, and holding one while
// `stages::fetch` opens a second onto the same directory fails in the same
// process. Written the other way round first, and it did.
stages::install();
let probe = stages::fetch(repo, want, haves)?;
// `StageRun::expect_path` rather than a rule of this file's own. This lane
// wrote one — presence of `select.bitmap`, absence of `select.tree_closure`
// — and `stages.rs` has since grown a stricter one that also reads whether
// a `.bitmap` was on disk, so a repository that has one and did not use it
// is named as such. One rule, in the crate that owns the stage table
// (LAW 5).
probe.expect_path(stages::SelectPath::Bitmap)?;
eprintln!(
"selection = {}; {} objects, {} wire bytes per request, {:.2} ms cpu",
probe.path,
probe.objects,
probe.wire_bytes,
probe.cpu_ns as f64 / 1e6
);
gunnar_stage::disable();
gunnar_stage::reset();
// Opened only now: `stages::fetch` above held the repository's writer lock
// for the length of its own call, and a second handle onto the same
// directory in the same process waits for the first forever.
let store = FsStore::open_or_init(repo, kind)?;
oidprobe::reset();
// The digest is taken on the FIRST and LAST iteration only, and the middle
// is served through `serve_on`, which does not hash. FNV-1a byte-at-a-time
// over a 6.59 MB response is ~8% of this profile and every byte of it is
// the harness; leaving it in every iteration would put the instrument in
// the denominator of the very share this loop exists to report. Two samples
// is enough for a request that is deterministic by construction — and if it
// ever is not, the two ends of the loop are exactly where a drift shows.
let t0 = std::time::Instant::now();
let mut digests = std::collections::BTreeSet::new();
let mut bytes = 0u64;
for i in 0..iters {
if i == 0 || i + 1 == iters {
let (digest, n, _) = stages::wire_digest_on(&store, kind, want, haves)?;
digests.insert(digest);
bytes += n;
} else {
let (out, _) = stages::serve_on(&store, kind, want, haves)?;
bytes += out.len() as u64;
}
}
let elapsed = t0.elapsed();
ensure!(
digests.len() == 1,
"the loop produced {} distinct responses; it is not serving one request repeatedly",
digests.len()
);
eprintln!(
"{iters} requests in {:.3} s ({:.2} ms each), {bytes} wire bytes total",
elapsed.as_secs_f64(),
elapsed.as_secs_f64() * 1000.0 / iters as f64
);
let snap = oidprobe::snapshot();
for (name, v) in &snap {
eprintln!(" {name} {v} ({:.3}/request)", *v as f64 / iters as f64);
}
check_entry_partitions(&snap, probe.objects * iters as u64)?;
Ok(())
}
fn looppush(
source: &std::path::Path,
work: &std::path::Path,
iters: usize,
tip: &ObjectId,
) -> Result<()> {
let _ = std::fs::remove_dir_all(work);
std::fs::create_dir_all(work)?;
let t0 = std::time::Instant::now();
let mut objects = 0u64;
for i in 0..iters {
let target = work.join(format!("t{i}.git"));
let run = stages::push(source, &target, tip, "refs/heads/censused")?;
objects += run.objects;
let _ = std::fs::remove_dir_all(&target);
}
let elapsed = t0.elapsed();
ensure!(objects > 0, "the push loop indexed ZERO objects");
eprintln!(
"{iters} pushes in {:.3} s ({:.2} ms each), {} objects each",
elapsed.as_secs_f64(),
elapsed.as_secs_f64() * 1000.0 / iters as f64,
objects / iters as u64
);
let _ = std::fs::remove_dir_all(work);
Ok(())
}
// ── the entry-size seam ─────────────────────────────────────────────────────
/// **What `location_by_oid` costs, and what the same answer costs from the
/// index.**
///
/// `gix_odb`'s `location_by_oid` (`gix-odb-0.83.0/src/store_impls/dynamic/
/// find.rs`) resolves an oid to a `Location`, and a `Location` carries
/// `entry_size` — how many bytes the entry occupies on disk. It obtains that
/// number by **fully inflating the entry**:
///
/// ```text
/// buf.resize(size, 0);
/// pack.decompress_entry(&entry, &mut inflate, buf)
/// .map(|past_header| Location { entry_size: entry.header_size() + past_header, .. })
/// ```
///
/// and then throws the inflated bytes away. gunnar calls it **exactly once per
/// object served** on the pack-copy path, so the path whose whole purpose is to
/// copy already-deflated bytes without inflating them inflates every one of
/// them first.
///
/// git does not. A pack's entries are contiguous, so the length of the entry at
/// offset `o` is the offset of the next entry minus `o` — which is a lookup in
/// the pack-order table git keeps in `.rev` and gix can rebuild from the `.idx`
/// in one pass.
///
/// This measures both and, **before either clock starts**, asserts they return
/// the identical `entry_size` for every object in the pack. A faster way to get
/// a different number is not a finding.
fn entrysize(repo: &std::path::Path, sample: usize) -> Result<()> {
let kind = gunnar_store::fs::stored_hash_kind(repo)?;
let pack_dir = repo.join("objects").join("pack");
let idx_paths: Vec<std::path::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();
ensure!(
idx_paths.len() == 1,
"{repo:?} holds {} pack indexes; the neighbour-offset rule is per pack and this probe \
does not model a multi-pack store",
idx_paths.len()
);
let idx =
gix_pack::index::File::at(&idx_paths[0], kind).map_err(|err| anyhow::anyhow!("{err}"))?;
let pack_path = idx_paths[0].with_extension("pack");
let pack_len = std::fs::metadata(&pack_path)?.len();
// The last entry ends where the trailing pack checksum begins.
//
// `GIXCENSUS_RED=lastentry` drops the `- hash` and makes the sentinel the
// end of the file, which is wrong for exactly one entry out of thousands.
// That is the red this guard has to be able to report: an equivalence check
// that passes over 65 737 entries and would also have passed if the rule
// were subtly wrong at the boundary is not a check. MEASURED oden
// 2026-08-04: it fails on the last entry of every repository, and on
// nothing else.
let red_last = std::env::var("GIXCENSUS_RED").is_ok_and(|v| v == "lastentry");
let pack_end = if red_last {
pack_len
} else {
pack_len - kind.len_in_bytes() as u64
};
// One pass over the `.idx`, sorted: the pack-order table. This is what
// `.rev` already holds on disk beside every one of these packs.
let t_build = std::time::Instant::now();
let mut offsets: Vec<u64> = (0..idx.num_objects())
.map(|i| idx.pack_offset_at_index(i))
.collect();
offsets.sort_unstable();
let build_ns = t_build.elapsed().as_nanos() as u64;
let entry_size_from_index = |off: u64| -> u64 {
let at = offsets.partition_point(|o| *o <= off);
let next = offsets.get(at).copied().unwrap_or(pack_end);
next - off
};
let store = gunnar_store::fs::FsStore::open_or_init(repo, kind)?;
let objects = store.objects();
let packed = gunnar_store::ObjectStore::packed(&objects)
.context("the store reports no pack-aware handle")?;
use gix_pack::Find as _;
// ── correctness first, over EVERY object in the pack ────────────────────
let mut buf = Vec::new();
let mut checked = 0u64;
let ids: Vec<gix_hash::ObjectId> = (0..idx.num_objects())
.map(|i| idx.oid_at_index(i).to_owned())
.collect();
for id in &ids {
let loc = packed
.location_by_oid(id, &mut buf)
.with_context(|| format!("{id} has no pack location"))?;
let mine = entry_size_from_index(loc.pack_offset);
ensure!(
mine == loc.entry_size as u64,
"{id} at offset {}: the index says {mine} bytes, the inflate says {}",
loc.pack_offset,
loc.entry_size
);
checked += 1;
}
ensure!(
checked > 1000,
"only {checked} objects were checked; an equivalence proven over a handful of entries is \
not proven"
);
// ── then the two clocks, over the same sample, same order ───────────────
let n = sample.min(ids.len());
let sampled = &ids[..n];
let mut sink = 0u64;
let t = std::time::Instant::now();
for id in sampled {
let loc = packed.location_by_oid(id, &mut buf).expect("located above");
sink = sink.wrapping_add(loc.entry_size as u64);
}
let gix_ns = t.elapsed().as_nanos() as u64;
let mut sink2 = 0u64;
let t = std::time::Instant::now();
for id in sampled {
let pos = idx.lookup(id).expect("in this index");
let off = idx.pack_offset_at_index(pos);
sink2 = sink2.wrapping_add(entry_size_from_index(off));
}
let idx_ns = t.elapsed().as_nanos() as u64;
ensure!(
sink == sink2,
"the two paths summed different entry sizes ({sink} against {sink2})"
);
println!(
"repo={} objects={} checked={} sample={n}",
repo.file_stem().unwrap().to_string_lossy(),
idx.num_objects(),
checked
);
println!(
" location_by_oid (inflates) {:>10.1} ns/entry {:.3} ms total",
gix_ns as f64 / n as f64,
gix_ns as f64 / 1e6
);
println!(
" index neighbour offset {:>10.1} ns/entry {:.3} ms total",
idx_ns as f64 / n as f64,
idx_ns as f64 / 1e6
);
println!(
" ratio {:.2}x; the pack-order table costs {:.3} ms to build once for {} objects \
({:.1} ns/object, amortised over every request the store answers)",
gix_ns as f64 / idx_ns.max(1) as f64,
build_ns as f64 / 1e6,
idx.num_objects(),
build_ns as f64 / idx.num_objects() as f64
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
ensure!(!args.is_empty(), "usage: see the module docs");
match args[0].as_str() {
"entrysize" => {
ensure!(args.len() >= 2, "entrysize <repo.git> [sample]");
entrysize(
std::path::Path::new(&args[1]),
args.get(2).and_then(|s| s.parse().ok()).unwrap_or(20_000),
)
}
"counts" => counts(args.get(1).and_then(|s| s.parse().ok()).unwrap_or(200)),
"loopfetch" => {
ensure!(
args.len() >= 4,
"loopfetch <repo.git> <iters> <want> [have ...]"
);
let haves: Vec<ObjectId> = args[4..].iter().map(|s| oid(s)).collect::<Result<_>>()?;
loopfetch(
std::path::Path::new(&args[1]),
args[2].parse()?,
&oid(&args[3])?,
&haves,
)
}
"looppush" => {
ensure!(
args.len() >= 5,
"looppush <source.git> <workdir> <iters> <tip>"
);
looppush(
std::path::Path::new(&args[1]),
std::path::Path::new(&args[2]),
args[3].parse()?,
&oid(&args[4])?,
)
}
other => anyhow::bail!("unknown subcommand {other:?}"),
}
}