bench/src/advertise.rs
//! **Timing the ref advertisement (v2 `ls-refs`) against a real store, counted.**
//!
//! The advertisement is the FIRST thing every clone, fetch and replication does,
//! and one was caught on the appliance costing 123 s for a 3 818-ref mirror
//! while a 41-ref repo answered sub-second. That single
//! observation confounds two variables — the big repo has both more refs *and*
//! more objects, and opening the store scales with objects, not refs — so it is
//! not a rate. This driver turns it into one: it opens the store **once**
//! (untimed) and then times only [`UploadPack::serve_v2_session`] on a
//! `command=ls-refs` with `peel`, so the number is per **advertised ref** with
//! the object count held constant across the sweep.
//!
//! # What it reuses, and why counts are the measurement
//!
//! It wraps the real engine's [`GitServe`] in [`crate::readside::Counting`], the
//! same decorator `gunnar.fetch_readside` uses, so a `read` (an inflated object
//! pull through the exploded table) and a `header` (an index-only kind/size
//! probe) are told apart. That count is machine-independent and is the red:
//! `advertise_from` peels **every annotated tag on every advertisement**, and
//! [`crate::readside`]'s `store_reads` shows how many full exploded reads that
//! costs per ref. On a store whose exploded table spans more than
//! `ExplodedTable::CACHE` (4) batches — any repository past ~256 MiB — those
//! reads land in traversal order, not file order, and thrash exactly as the
//! receive path did when it was measured on 2026-08-19.
use std::io::Cursor;
use std::path::Path;
use std::time::Instant;
use anyhow::{Context, Result};
use gunnar_wire::upload_pack::UploadPack;
use gunnar_wire::Session;
use crate::readside::{Counting, Tally};
fn pkt(payload: &str) -> Vec<u8> {
let mut out = format!("{:04x}", payload.len() + 4).into_bytes();
out.extend_from_slice(payload.as_bytes());
out
}
/// A v2 `command=ls-refs` asking for `peel` + `symrefs` over `refs/` — the exact
/// shape `git ls-remote` and the opening round of every clone send.
fn ls_refs_request(kind: gix_hash::Kind) -> Vec<u8> {
let fmt = match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
let mut req = Vec::new();
req.extend(pkt("command=ls-refs\n"));
req.extend(pkt(&format!("object-format={fmt}\n")));
req.extend_from_slice(b"0001");
req.extend(pkt("peel\n"));
req.extend(pkt("symrefs\n"));
req.extend(pkt("ref-prefix refs/\n"));
req.extend_from_slice(b"0000");
req
}
fn count_occurrences(haystack: &[u8], needle: &[u8]) -> usize {
if needle.is_empty() || haystack.len() < needle.len() {
return 0;
}
let mut n = 0;
let mut i = 0;
while i + needle.len() <= haystack.len() {
if &haystack[i..i + needle.len()] == needle {
n += 1;
i += needle.len();
} else {
i += 1;
}
}
n
}
/// One advertisement's cost and shape.
#[derive(Debug, Clone)]
pub struct Advertisement {
/// Refs the store holds (`GitServe::refs`), i.e. what every advertisement
/// iterates.
pub refs_held: usize,
/// `peeled:` lines the advertisement emitted — the annotated tags it peeled.
pub peeled_lines: usize,
/// Best wall of `reps` timed runs, nanoseconds. Steady state: the store was
/// warmed first, so this is what a client pays on a warm box, not a cold
/// open.
pub best_wall_ns: u64,
/// `GitServe::read` calls one advertisement made — full inflated pulls
/// through the exploded table. The tag re-peeling this arm is about.
pub reads: u64,
/// Inflated bytes those reads returned.
pub read_bytes: u64,
/// `GitServe::header`/`sizes` calls — index-only, cheap.
pub headers: u64,
/// Reads of an oid already read this advertisement.
pub rereads: u64,
/// Per-kind read split (`commit`/`tree`/`blob`/`tag`).
pub kinds: std::collections::BTreeMap<&'static str, (u64, u64)>,
}
impl Advertisement {
/// Wall microseconds per advertised ref — the advertisement rate.
pub fn us_per_ref(&self) -> f64 {
if self.refs_held == 0 {
return 0.0;
}
(self.best_wall_ns as f64 / 1000.0) / self.refs_held as f64
}
/// Full exploded reads per advertised ref.
pub fn reads_per_ref(&self) -> f64 {
if self.refs_held == 0 {
return 0.0;
}
self.reads as f64 / self.refs_held as f64
}
}
/// Open `store_dir` on the selected engine and measure one `ls-refs`.
///
/// `warmups` runs prime the exploded batch cache to the same steady state a busy
/// server sits in; `reps` timed runs are reduced best-of. The op-count is taken
/// from one final clean run so re-reads read as one advertisement's, not the
/// sum.
pub fn probe(
store_dir: &Path,
kind: gix_hash::Kind,
warmups: usize,
reps: usize,
) -> Result<Advertisement> {
probe_impl(store_dir, kind, warmups, reps, false)
}
/// [`probe`], but `posix_fadvise(DONTNEED)` the exploded table before **each**
/// timed run so every batch a peel misses in the in-process `CACHE` is a real
/// cold decode from disk — the cache-pressured regime the appliance's
/// multi-GB `linux-mirror` sits in, which a 410 MiB fixture that stays wholly in
/// page cache does not otherwise reproduce.
pub fn probe_cold(store_dir: &Path, kind: gix_hash::Kind, reps: usize) -> Result<Advertisement> {
probe_impl(store_dir, kind, 0, reps, true)
}
fn probe_impl(
store_dir: &Path,
kind: gix_hash::Kind,
warmups: usize,
reps: usize,
cold: bool,
) -> Result<Advertisement> {
// Open ONCE, outside every timed region — opening scales with objects, and
// folding it in is exactly the confound that single appliance observation carries.
let store = crate::engine::open_repo(store_dir, kind)
.with_context(|| format!("opening {store_dir:?}"))?;
let git = store.git();
let refs_held = git.refs().context("listing refs")?.len();
let req = ls_refs_request(kind);
let serve_once = |collect_out: bool| -> Result<(u64, Vec<u8>, Tally)> {
let tally = Tally::default();
let counting = Counting {
inner: git.as_ref(),
tally: &tally,
};
let upload = UploadPack::new(&counting, kind);
let mut input = Cursor::new(req.clone());
let mut out = Vec::new();
let start = Instant::now();
// `StatelessRpc`: no inline capability advertisement, and the call
// returns after the one command — so the clock brackets `ls-refs` and
// nothing else.
upload.serve_v2_session(Session::StatelessRpc, &mut input, &mut out)?;
let wall = start.elapsed().as_nanos() as u64;
Ok((wall, if collect_out { out } else { Vec::new() }, tally))
};
for _ in 0..warmups {
serve_once(false)?;
}
let mut best = u64::MAX;
let mut peeled_lines = 0;
for _ in 0..reps.max(1) {
if cold {
// Drop the exploded table's pages so a `CACHE` miss re-reads disk.
// The in-process decoded-batch `CACHE` persists across runs in the
// store handle, so this measures the miss-that-hits-disk cost, not a
// cold OPEN (which is untimed above).
crate::readside::evict(store_dir).ok();
}
let (wall, out, _) = serve_once(true)?;
best = best.min(wall);
peeled_lines = count_occurrences(&out, b"peeled:");
}
// One clean run for the op-counts.
let (_, _, tally) = serve_once(false)?;
Ok(Advertisement {
refs_held,
peeled_lines,
best_wall_ns: best,
reads: tally.reads(),
read_bytes: tally.read_bytes(),
headers: tally.headers(),
rereads: tally.rereads(),
kinds: tally.kinds(),
})
}