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

nordisk/edda

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:?}"),
    }
}