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

nordisk/gunnar

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(),
    })
}