bench/src/stages.rs
//! Serving one request, **broken into named stages**. See [`fetch`] and
//! [`push`].
//!
//! # What this is for
//!
//! Every other arm in this harness measures a whole operation and reports one
//! number. When the number moves, nothing says which stage moved; when gunnar
//! loses, nothing says where. Two results on 2026-08-04 stalled on exactly
//! that, and both are questions about *stages* rather than about symbols:
//!
//! * CPU per request rises 2.54x from 1 to 32 concurrent clients while gitea's
//! falls — [`concurrent_fetch`] runs the same decomposition at both widths;
//! * a negotiated fetch costs 2.2–2.6x on the znippy backend where a warm clone
//! costs 1.06–1.41x, narrowed to "object reads, not graph traversal" —
//! [`fetch`] runs both shapes against the same repository and charges every
//! `ObjectStore::read` to the stage that issued it.
//!
//! # The instrument
//!
//! [`gunnar_stage`] does the bookkeeping and is compiled into `gunnar-wire`
//! only under its `stages` feature, which `bench/Cargo.toml` is the only thing
//! in the tree that turns on. This module installs the three facts that crate
//! deliberately refuses to gather for itself:
//!
//! | hook | source | why not in `gunnar-stage` |
//! |---|---|---|
//! | per-thread user/sys CPU | `getrusage(RUSAGE_THREAD)` | needs `libc`, outside the dependency budget |
//! | resident set | `/proc/self/statm` | ditto, and it is process-wide |
//! | allocation count/bytes | [`CountingAlloc`] | a `#[global_allocator]` is a binary's to choose |
//!
//! # Every column has been seen to report zero for the right reason
//!
//! `getrusage` failing, no RSS probe installed, no counting allocator linked —
//! each of those produces a **zero column**, and a zero column is shaped
//! exactly like "this stage is free". `pack_stats` shipped a persisted run that
//! way (busybox has no `find -printf`, the pipeline exited 0 through `cat`, and
//! it reported 0 packs). So [`Instruments`] records which hooks are actually
//! installed and every report carries it, and the arms assert non-vacuity
//! rather than trusting a tidy table.
use std::alloc::{GlobalAlloc, Layout};
use std::collections::BTreeMap;
use anyhow::{ensure, Context, Result};
use gix_hash::ObjectId;
use gunnar_server::SERVER_ALLOCATOR;
use gunnar_stage::{Report, Tally};
use gunnar_store::git_storage_trait::{
Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas, RefRow,
RefUpdate, Stored, TxId,
};
use gunnar_wire::upload_pack::UploadPack;
// ── the hooks ───────────────────────────────────────────────────────────────
/// `getrusage(RUSAGE_THREAD)`, in nanoseconds.
///
/// **`RUSAGE_THREAD` and not `RUSAGE_SELF`.** The concurrency question is a
/// question about 32 threads; `RUSAGE_SELF` sums all of them and would report
/// every worker as costing what the whole process cost. And not
/// `/proc/self/task/<tid>/stat` either: that answers in 10 ms clock ticks,
/// which is coarser than fourteen of the eighteen stages are long, so most of
/// the table would be a confident zero.
pub(crate) fn thread_cpu_ns() -> (u64, u64) {
// SAFETY: `getrusage` writes a `struct rusage` through the pointer and
// reads nothing else. The zeroed value is a valid `rusage`.
let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
if unsafe { libc::getrusage(libc::RUSAGE_THREAD, &mut usage) } != 0 {
return (0, 0);
}
let ns = |t: libc::timeval| (t.tv_sec as u64) * 1_000_000_000 + (t.tv_usec as u64) * 1_000;
(ns(usage.ru_utime), ns(usage.ru_stime))
}
/// `getrusage(RUSAGE_SELF)`: the **whole process**, every thread, in
/// nanoseconds.
///
/// The coverage check, and the reason it is not optional. `RUSAGE_THREAD` sees
/// only the thread that entered the stage, so any CPU a stage spends on worker
/// threads it spawned is invisible to the attribution — and it is not
/// hypothetical: `gix-pack` is built with `parallel` here, so
/// `index_thin_pack` fans out and the push's `pack_ingest` stage is charged
/// only its own thread. A table whose columns quietly describe a fraction of
/// the work is exactly what "measure, never assert" is about, so every report carries
/// [`StageRun::process_cpu_ns`] beside the stage sum and
/// [`StageRun::attributed_frac`] between them.
fn process_cpu_ns() -> u64 {
// SAFETY: as `thread_cpu_ns`.
let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 {
return 0;
}
let ns = |t: libc::timeval| (t.tv_sec as u64) * 1_000_000_000 + (t.tv_usec as u64) * 1_000;
ns(usage.ru_utime) + ns(usage.ru_stime)
}
/// Resident set in bytes, out of `/proc/self/statm`, **without allocating**.
///
/// Raw `open`/`read`/`close` into a stack buffer rather than
/// `fs::read_to_string`, because this runs at a stage boundary and every byte
/// it allocated would be charged to the stage it is measuring. An instrument
/// that shows up in its own table is not one.
fn rss_bytes() -> u64 {
const PATH: &[u8] = b"/proc/self/statm\0";
// SAFETY: `PATH` is NUL-terminated; `buf` is written at most `buf.len()`.
unsafe {
let fd = libc::open(PATH.as_ptr() as *const libc::c_char, libc::O_RDONLY);
if fd < 0 {
return 0;
}
let mut buf = [0u8; 128];
let n = libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len() - 1);
libc::close(fd);
if n <= 0 {
return 0;
}
let text = std::str::from_utf8_unchecked(&buf[..n as usize]);
// "size resident shared text lib data dt", in pages.
let Some(resident) = text.split_whitespace().nth(1) else {
return 0;
};
let pages: u64 = resident.parse().unwrap_or(0);
pages * (libc::sysconf(libc::_SC_PAGESIZE) as u64)
}
}
/// A `#[global_allocator]` that tells [`gunnar_stage`] the size of every block,
/// **per thread**.
///
/// # Why the binary installs it and not this module
///
/// `#[global_allocator]` is a property of the whole executable. Installing one
/// in `examples/nornir-bench.rs` unconditionally would put a branch in front of
/// every allocation in six other lanes' arms, whose rows land in the same
/// table. It is therefore behind the `count-alloc` cargo feature, default off,
/// and `examples/stage-probe.rs` — which measures nothing else — has it on
/// always.
///
/// # The blindness this shape avoids
///
/// A counting allocator written in this repository before counted per
/// **process** while `cargo test` ran its cases on threads, so every byte
/// landed on whichever case happened to be running.
/// [`gunnar_stage::note_alloc`] touches nothing but thread-local `Cell`s, and
/// `gunnar-stage/tests/attribution.rs` holds two barrier-synchronised threads
/// doing deliberately lopsided work as the proof.
/// # It wraps the allocator the server ships, not `System`
///
/// `System` until 2026-08-11. The server's heap is
/// [`gunnar_server::ServerAllocator`] — mimalloc in every shipping build, worth
/// 2.2x on clone CPU — and a probe that counted `System` bytes instead would be
/// reporting an allocator no gunnar runs on. Same failure family as the zero
/// columns this module's header is about: a plausible number measured off the
/// real path.
///
/// The counts themselves are allocator-independent (they are `layout.size()`,
/// what the *program* asked for), so this changes no historical row. What it
/// fixes is the timing that surrounds them and any future column that reads
/// back what the allocator actually did.
pub struct CountingAlloc;
// SAFETY: every method forwards to `SERVER_ALLOCATOR` unchanged and the
// accounting call allocates nothing, so it cannot re-enter this allocator.
unsafe impl GlobalAlloc for CountingAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let p = SERVER_ALLOCATOR.alloc(layout);
if !p.is_null() {
gunnar_stage::note_alloc(layout.size());
}
p
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
gunnar_stage::note_dealloc(layout.size());
SERVER_ALLOCATOR.dealloc(ptr, layout)
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let p = SERVER_ALLOCATOR.realloc(ptr, layout, new_size);
if !p.is_null() {
gunnar_stage::note_dealloc(layout.size());
gunnar_stage::note_alloc(new_size);
}
p
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let p = SERVER_ALLOCATOR.alloc_zeroed(layout);
if !p.is_null() {
gunnar_stage::note_alloc(layout.size());
}
p
}
}
/// Which of the four columns are actually being measured on this run.
///
/// Carried into every report so a zero can be read as "measured zero" or as
/// "not measured", which is the distinction `pack_stats` lost.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Instruments {
/// A per-thread CPU clock is installed and answered non-zero at least once.
pub cpu: bool,
/// An RSS probe is installed and answered non-zero.
pub rss: bool,
/// The counting allocator is linked.
pub alloc: bool,
}
/// Install the CPU clock, and the RSS probe unless `GUNNAR_STAGE_NO_RSS` is
/// set.
///
/// The RSS probe is three syscalls per stage boundary. On the eighteen-boundary
/// fetch that is ~54 syscalls, which is nothing beside the fetch — but the
/// concurrency arm runs it 32 ways at once against one `/proc` and the number
/// it reports there is process-wide anyway, so that arm turns it off and says
/// `rss: false` rather than publishing a figure that means nothing.
pub fn install() -> Instruments {
gunnar_stage::set_cpu_clock(thread_cpu_ns);
let want_rss = std::env::var_os("GUNNAR_STAGE_NO_RSS").is_none();
if want_rss {
gunnar_stage::set_rss_probe(rss_bytes);
}
Instruments {
cpu: thread_cpu_ns() != (0, 0),
rss: want_rss && rss_bytes() > 0,
alloc: alloc_counting_linked(),
}
}
/// Whether a counting allocator is actually in this binary.
///
/// Asked of the accounting rather than of a `cfg!`, because the `cfg!` is true
/// in the crate that *defines* [`CountingAlloc`] whether or not the binary
/// installed it. Allocates a vector under a stage and looks for it.
fn alloc_counting_linked() -> bool {
gunnar_stage::reset();
gunnar_stage::enable();
{
let _s = gunnar_stage::enter("__alloc_selftest__");
let probe: Vec<u8> = Vec::with_capacity(4096);
std::hint::black_box(&probe);
}
let seen = gunnar_stage::thread_report()
.get("__alloc_selftest__")
.map(|t| t.excl_alloc_bytes >= 4096)
.unwrap_or(false);
gunnar_stage::disable();
gunnar_stage::reset();
seen
}
// ── the reports ─────────────────────────────────────────────────────────────
/// One stage's share of one request.
#[derive(Debug, Clone)]
pub struct StageRow {
/// The stage's name.
pub name: &'static str,
/// Its share of the request's exclusive wall time, 0.0–1.0.
pub wall_frac: f64,
/// Its share of the request's exclusive CPU (user + sys), 0.0–1.0.
pub cpu_frac: f64,
/// Its share of the request's peak growth, 0.0–1.0.
pub peak_frac: f64,
/// The tally the fractions come from, so an absolute figure is one field
/// away and never has to be reconstructed from a percentage.
pub tally: Tally,
/// `ObjectStore::read` calls issued while this stage was innermost.
pub store_reads: u64,
/// Inflated bytes those reads returned.
pub store_read_bytes: u64,
}
/// Which arm of `select_objects` actually answered this request.
///
/// # Why a decomposition is worthless without it
///
/// `select_objects` consults `graph.store().bitmap()` **first** and returns
/// before the walk when it answers (`gunnar-wire/src/graph.rs:1459`). The two
/// paths select the **same object set**, so no correctness test can tell them
/// apart, and [`wire_digest`] — this instrument's own fidelity check — agreed
/// across nineteen repositories while every measurement was silently taken on
/// the walk, because `StageCounting` forwarded every `ObjectStore` method
/// except `bitmap()`, whose trait default is `None`, and so turned the bitmap
/// OFF for everything measured through it.
///
/// (The *bytes* are not identical, which nobody had measured either: the two
/// paths carry different `Selection::client_has` shapes — an id set against a
/// bitmap `Vouch` — so the thin pack can name a different set of delta bases.
/// MEASURED the desktop 2026-08-04 over six corpus repositories at 200 commits behind,
/// the bitmap path emits **0.863x–0.999x** the wire bytes of the walk for an
/// identical object count. It is the object set that is invariant, not the
/// encoding.)
///
/// The lesson generalises past this bug and is the reason this enum exists
/// rather than a comment: **an instrument's fidelity check must also assert
/// that the path it thinks it measured is the path that ran.** Comparing
/// outputs is blind to a switch that does not change the output. So every run
/// now says which arm answered, [`StageRun::expect_path`] refuses a run that
/// took the other one, and the arms declare what they expect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectPath {
/// The `.bitmap` answered. The walk stages are absent from the table
/// entirely — the bitmap does not shrink the walk, it replaces it.
Bitmap,
/// The commit and tree walk ran.
Walk,
/// Neither, which means `select_objects` was never reached — a fetch that
/// sent no pack, or a table taken from the wrong window.
Neither,
}
impl std::fmt::Display for SelectPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SelectPath::Bitmap => "bitmap",
SelectPath::Walk => "walk",
SelectPath::Neither => "neither",
})
}
}
/// A whole request, decomposed.
#[derive(Debug, Clone)]
pub struct StageRun {
/// Every stage, largest exclusive wall share first.
pub rows: Vec<StageRow>,
/// Total exclusive wall, which is the request.
pub wall_ns: u64,
/// Total exclusive CPU.
pub cpu_ns: u64,
/// Total exclusive user CPU.
pub user_ns: u64,
/// Total exclusive system CPU.
pub sys_ns: u64,
/// What the **whole process** burned across the same window, every thread
/// included. See [`process_cpu_ns`].
pub process_cpu_ns: u64,
/// Bytes the server put on the wire.
pub wire_bytes: u64,
/// Objects it said it sent.
pub objects: u64,
/// What was measurable.
pub instruments: Instruments,
/// Which arm of `select_objects` answered. See [`SelectPath`].
pub path: SelectPath,
/// Whether a `.bitmap` file was on disk beside the pack when this ran.
///
/// Read from the filesystem, not inferred from the table, so the two can be
/// checked **against each other**: a repository that has one and did not
/// use it is the defect this field exists to make impossible to miss again.
pub bitmap_on_disk: bool,
}
impl StageRun {
/// Fail unless the request took the arm the caller expected.
///
/// The assertion that was missing. An arm that means to measure production
/// asks for [`SelectPath::Bitmap`]; one that means to measure the walk
/// forces `GUNNAR_BITMAP=off` and asks for [`SelectPath::Walk`]. Either one
/// getting the other is a silent change of subject, and the fractions it
/// produces are perfectly plausible.
pub fn expect_path(&self, want: SelectPath) -> Result<()> {
ensure!(
self.path == want,
"this run took the {} arm of `select_objects` and the caller was measuring the {} \
one. A `.bitmap` was {} on disk. The two arms select the SAME OBJECT SET, so \
nothing downstream fails and no correctness test notices: the table would simply \
have described a server nobody runs",
self.path,
want,
if self.bitmap_on_disk {
"present"
} else {
"absent"
}
);
Ok(())
}
/// How much of the process's CPU the stage table actually accounts for.
///
/// Near 1.0 means the decomposition is the request. Well below 1.0 means
/// work happened on threads no stage ever entered — `gix-pack`'s parallel
/// index resolution is the known case — and every fraction in the table is
/// then a fraction of the attributed part only, which the arms say out
/// loud rather than leaving to be discovered.
///
/// Can exceed 1.0 slightly: the two clocks are read at different instants
/// and `RUSAGE_SELF` has microsecond granularity.
pub fn attributed_frac(&self) -> f64 {
if self.process_cpu_ns == 0 {
return 0.0;
}
self.cpu_ns as f64 / self.process_cpu_ns as f64
}
/// The share of one named stage, or 0.0 if it did not run.
pub fn wall_frac(&self, name: &str) -> f64 {
self.rows
.iter()
.find(|r| r.name == name)
.map_or(0.0, |r| r.wall_frac)
}
/// The store reads one named stage issued, or 0 — the unit a probe that
/// moves *reads* between stages has to assert in, because the price of a
/// read is the engine's and changes under the table (see the RED probe in
/// `gunnar.stage_fetch`).
pub fn store_reads(&self, name: &str) -> u64 {
self.rows
.iter()
.find(|r| r.name == name)
.map_or(0, |r| r.store_reads)
}
/// The CPU share of one named stage, or 0.0.
pub fn cpu_frac(&self, name: &str) -> f64 {
self.rows
.iter()
.find(|r| r.name == name)
.map_or(0.0, |r| r.cpu_frac)
}
/// The stage with the largest exclusive CPU share.
pub fn cpu_owner(&self) -> &'static str {
self.rows
.iter()
.max_by(|a, b| a.cpu_frac.total_cmp(&b.cpu_frac))
.map_or("none", |r| r.name)
}
/// The stage that grew the thread's live-bytes high-water mark most.
pub fn memory_owner(&self) -> &'static str {
self.rows
.iter()
.max_by(|a, b| a.peak_frac.total_cmp(&b.peak_frac))
.map_or("none", |r| r.name)
}
}
fn shape(
report: &Report,
reads: &BTreeMap<String, (u64, u64)>,
wire_bytes: u64,
objects: u64,
process_cpu_ns: u64,
instruments: Instruments,
bitmap_on_disk: bool,
) -> StageRun {
// Derived from what RAN, not from what was configured. `select.bitmap` is
// entered whenever bitmaps are enabled — including when the bitmap then
// DECLINES the request shape and falls through — so its presence proves
// nothing. The discriminator is whether the walk happened.
let ran = |name: &str| report.contains_key(name);
let path = if ran("select.tree_closure") || ran("select.commit_walk") {
SelectPath::Walk
} else if ran("select.bitmap") {
SelectPath::Bitmap
} else {
SelectPath::Neither
};
let wall_ns = gunnar_stage::total_excl_wall_ns(report);
let cpu_ns = gunnar_stage::total_excl_cpu_ns(report);
let user_ns: u64 = report.values().map(|t| t.excl_user_ns).sum();
let sys_ns: u64 = report.values().map(|t| t.excl_sys_ns).sum();
let peak_total: u64 = report.values().map(|t| t.peak_growth_bytes).sum();
let over = |n: u64, d: u64| if d == 0 { 0.0 } else { n as f64 / d as f64 };
let mut rows: Vec<StageRow> = report
.iter()
.map(|(name, tally)| {
let (store_reads, store_read_bytes) = reads.get(*name).copied().unwrap_or((0, 0));
StageRow {
name,
wall_frac: over(tally.excl_wall_ns, wall_ns),
cpu_frac: over(tally.excl_user_ns + tally.excl_sys_ns, cpu_ns),
peak_frac: over(tally.peak_growth_bytes, peak_total),
tally: *tally,
store_reads,
store_read_bytes,
}
})
.collect();
rows.sort_by(|a, b| b.wall_frac.total_cmp(&a.wall_frac));
StageRun {
rows,
wall_ns,
cpu_ns,
user_ns,
sys_ns,
process_cpu_ns,
wire_bytes,
objects,
instruments,
path,
bitmap_on_disk,
}
}
/// Whether a `.bitmap` sits beside a pack in this repository.
pub fn has_bitmap(repo: &std::path::Path) -> bool {
std::fs::read_dir(repo.join("objects").join("pack"))
.map(|dir| {
dir.flatten()
.any(|e| e.path().extension().is_some_and(|x| x == "bitmap"))
})
.unwrap_or(false)
}
// ── the counting store ──────────────────────────────────────────────────────
/// A [`GitServe`] that charges every read to the stage that issued it.
///
/// This is the half a stage timer cannot supply and the half the znippy
/// question needs: "the fetch read 12 000 objects" and "the tree walk read
/// 11 400 of them and the pack writer re-read 600" are different findings with
/// different fixes, and only the second one points anywhere.
///
/// Ported off `gunnar_store::ObjectStore` on 2026-08-10 with the rest of the
/// tree. Only [`GitServe::read`] is charged, as before — this type answers
/// *"which stage inflated how much"*, and an existence probe inflates nothing.
struct StageCounting<'a> {
inner: &'a dyn GitServe,
reads: std::sync::Mutex<BTreeMap<String, (u64, u64)>>,
}
impl<'a> StageCounting<'a> {
fn new(inner: &'a dyn GitServe) -> Self {
StageCounting {
inner,
reads: std::sync::Mutex::new(BTreeMap::new()),
}
}
fn note(&self, bytes: u64) {
let stage = gunnar_stage::current().unwrap_or("__outside__");
let mut map = self.reads.lock().expect("the stage read map");
let entry = map.entry(stage.to_owned()).or_insert((0, 0));
entry.0 += 1;
entry.1 += bytes;
}
fn into_reads(self) -> BTreeMap<String, (u64, u64)> {
self.reads.into_inner().expect("the stage read map")
}
}
/// The twelve, forwarded and uncounted: this type attributes *inflation* to a
/// stage, and none of the twelve inflates anything.
impl GitOps for StageCounting<'_> {
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 has(&self, oid: Oid<'_>) -> anyhow::Result<bool> {
self.inner.has(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 GitServe for StageCounting<'_> {
fn read(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, Vec<u8>)>> {
let out = self.inner.read(oid)?;
if let Some((_, bytes)) = out.as_ref() {
self.note(bytes.len() as u64);
}
Ok(out)
}
fn header(&self, oid: Oid<'_>) -> anyhow::Result<Option<(ObjType, u64)>> {
self.inner.header(oid)
}
fn sizes(&self, oids: &[Oid<'_>]) -> anyhow::Result<Vec<Option<u64>>> {
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)
}
}
// ── the drivers ─────────────────────────────────────────────────────────────
/// A pkt-line carrying `payload`.
pub 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 protocol-v2 `command=fetch`, in the shape stock git sends.
///
/// `haves` empty is a **clone**: nothing to negotiate away, every base is in
/// the transfer. A non-empty `haves` with `done` is the final round of an
/// incremental fetch, which is where the 2.2–2.6x lives.
pub fn fetch_request(want: &ObjectId, haves: &[ObjectId], format: &str) -> Vec<u8> {
let mut req = Vec::new();
req.extend(pkt("command=fetch\n"));
req.extend(pkt(&format!("object-format={format}\n")));
req.extend_from_slice(b"0001");
req.extend(pkt("thin-pack\n"));
req.extend(pkt("no-progress\n"));
req.extend(pkt("ofs-delta\n"));
req.extend(pkt(&format!("want {want}\n")));
for have in haves {
req.extend(pkt(&format!("have {have}\n")));
}
req.extend(pkt("done\n"));
req.extend_from_slice(b"0000");
req
}
/// Serve one fetch and hash what went on the wire: `(digest, bytes, objects)`.
///
/// **The correctness bar.** Instrumentation that changes what it measures is
/// worthless, and "the fetch still succeeded" is true of a server that emitted
/// different bytes. This takes the response verbatim — advertisement,
/// acknowledgments, sidebands, packfile and all — so a probe that perturbed the
/// pack writer, the selection order or the flush pattern shows up as a
/// different digest rather than as a benchmark that still passes.
///
/// It deliberately does **not** touch [`gunnar_stage::enable`]: the caller sets
/// the recording state, which is what lets one binary compare recording against
/// not-recording, and two binaries compare compiled-in against compiled-out.
///
/// FNV-1a 64 rather than a cryptographic digest, with the byte count and the
/// object count beside it. Nothing adversarial is being defended against; what
/// is being detected is an implementation that changed, and a length plus a
/// 64-bit hash detects that.
pub fn wire_digest(
repo: &std::path::Path,
want: &ObjectId,
haves: &[ObjectId],
) -> Result<(String, u64, u64)> {
let kind = stored_hash_kind(repo)?;
let store = crate::engine::open_repo(repo, kind)?;
wire_digest_on(store.as_ref(), kind, want, haves)
}
/// **Many requests, one open store, one digest each — and the reason it exists
/// is that [`wire_digest`] over a single request is BLIND.**
///
/// A fetch touches only the objects its own want reaches. A server whose
/// reachability index, `have` closure or voucher is wrong for *every other*
/// commit in the repository still answers that one request byte-for-byte
/// correctly, so a fidelity check built on one `(want, have)` pair passes a
/// store that cannot serve anything else. Learned the hard way on this exact
/// code (`select`/`client_bases`), which is why the corpus is a *set* of
/// distinct wants and this function does not take a single one.
///
/// It is deliberately the **warm** shape — [`wire_digest`]'s per-call open would
/// re-derive the index for every pair and hide an answer that depends on which
/// request came first, which is a class of bug this is meant to catch rather
/// than to avoid.
///
/// # Both directions, and they are not symmetric
///
/// The `have` set feeds `union - had`, so a corrupt one enters sign-flipped:
/// **dropping** an entry OVER-sends and **adding** one UNDER-sends. An
/// under-send is silent data loss in a clone; an over-send is bandwidth no
/// connectivity check will ever complain about. The digest catches both because
/// it is over the bytes; the object count returned beside it says *which*.
pub fn wire_digest_set(
repo: &std::path::Path,
requests: &[(ObjectId, Vec<ObjectId>)],
) -> Result<Vec<(String, u64, u64)>> {
ensure!(
!requests.is_empty(),
"a digest over an empty request set is a digest of nothing, and it would compare equal \
to any other digest of nothing"
);
let kind = stored_hash_kind(repo)?;
let store = crate::engine::open_repo(repo, kind)?;
requests
.iter()
.map(|(want, haves)| {
wire_digest_on(store.as_ref(), kind, want, haves)
.with_context(|| format!("serving want {want} with {} have(s)", haves.len()))
})
.collect()
}
/// The same, served out of a store the caller **already holds open**.
///
/// The production shape, and a different measurement: a server opens a
/// repository once and answers requests out of it for weeks, so everything
/// `engine::open_repo` and `BitmapCache` do at open — mapping the `.idx`,
/// parsing the `.bitmap`, building the pack-order permutation and any derived
/// index beside it — is paid once and not per request. [`wire_digest`] pays all
/// of it every call, which is the right shape for a cold-start question and the
/// wrong one for a throughput question.
///
/// Both spellings run the same state machine over the same store type; only who
/// owns the open differs; there is one implementation, not two.
pub fn wire_digest_on(
store: &dyn gunnar_store::RepoStore,
kind: gix_hash::Kind,
want: &ObjectId,
haves: &[ObjectId],
) -> Result<(String, u64, u64)> {
let out = serve_on(store, kind, want, haves)?;
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in &out.0 {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
Ok((format!("{hash:016x}"), out.0.len() as u64, out.1))
}
/// Serve one fetch out of an already-open store and hand back the raw
/// response: `(bytes, objects)`.
///
/// The half of [`wire_digest_on`] that is the server, split out so a profiling
/// loop can run the serve path **without** the FNV pass over the response.
/// That pass is byte-at-a-time over the whole packfile — on a 6.59 MB response
/// it is ~8% of the profile, all of it the harness, and it lands in the
/// denominator of every share the profile reports. `wire_digest_on` is defined
/// in terms of this rather than beside it, so the digest the fidelity check
/// compares is a digest of exactly the bytes the profiled loop produced, out
/// of one implementation rather than two.
pub fn serve_on(
store: &dyn gunnar_store::RepoStore,
kind: gix_hash::Kind,
want: &ObjectId,
haves: &[ObjectId],
) -> Result<(Vec<u8>, u64)> {
// One handle. `objects()`/`refs()` were the `ObjectStore`/`RefStore` split;
// `RepoStore::git()` returns the single `Arc<dyn GitServe>` that owns both.
let git = store.git();
let format = match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
let upload = UploadPack::new(git.as_ref(), kind);
let mut input =
std::io::BufReader::new(std::io::Cursor::new(fetch_request(want, haves, format)));
let mut out: Vec<u8> = Vec::new();
let summary = upload.serve_v2(&mut input, &mut out)?;
ensure!(
summary.objects_sent > 0,
"the served response carried ZERO objects; two empty responses are equal"
);
Ok((out, u64::from(summary.objects_sent)))
}
/// Serve **one** fetch out of `repo` and attribute it.
///
/// The store, the state machine and the pack writer are the real ones; only the
/// socket is a `Cursor`, which is what makes this a decomposition of the serve
/// path rather than of a benchmark's imitation of it. What it therefore does
/// **not** include is `accept(2)`, the SSH handshake and the kernel's share of
/// moving bytes down a socket — see `gunnar.stage_fetch`'s doc comment for what
/// that leaves out and how big it is.
pub fn fetch(repo: &std::path::Path, want: &ObjectId, haves: &[ObjectId]) -> Result<StageRun> {
let instruments = install();
let kind =
stored_hash_kind(repo).with_context(|| format!("reading the hash kind of {repo:?}"))?;
let store =
crate::engine::open_repo(repo, kind).with_context(|| format!("opening {repo:?}"))?;
let git = store.git();
let counting = StageCounting::new(git.as_ref());
let format = match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
let request = fetch_request(want, haves, format);
let upload = UploadPack::new(&counting, kind);
let mut input = std::io::BufReader::new(std::io::Cursor::new(request));
let mut out: Vec<u8> = Vec::new();
gunnar_stage::reset();
gunnar_stage::enable();
let cpu0 = process_cpu_ns();
let summary = upload.serve_v2(&mut input, &mut out)?;
let cpu1 = process_cpu_ns();
gunnar_stage::disable();
let report = gunnar_stage::thread_report();
ensure!(
summary.objects_sent > 0,
"the decomposed fetch sent ZERO objects, so every fraction below is a share of nothing"
);
ensure!(
!report.is_empty(),
"the serve path recorded NO stages. The `stages` feature is off, or `gunnar-wire` was \
resolved without it — either way the table would be empty rather than wrong, which is \
the one failure mode that looks like a clean result"
);
Ok(shape(
&report,
&counting.into_reads(),
out.len() as u64,
u64::from(summary.objects_sent),
cpu1.saturating_sub(cpu0),
instruments,
has_bitmap(repo),
))
}
/// A protocol-v2 `command=ls-refs`, in the shape `git ls-remote` sends.
///
/// `peel` and `symrefs` are what stock git asks for, and they matter to a
/// census: `peel` is what makes the server read tag objects out of the store,
/// so an `ls-refs` request without it would report the one command that touches
/// the object database as touching none of it.
pub fn ls_refs_request(format: &str) -> Vec<u8> {
let mut req = Vec::new();
req.extend(pkt("command=ls-refs\n"));
req.extend(pkt(&format!("object-format={format}\n")));
req.extend_from_slice(b"0001");
req.extend(pkt("peel\n"));
req.extend(pkt("symrefs\n"));
req.extend(pkt("unborn\n"));
req.extend_from_slice(b"0000");
req
}
/// Serve **one** `ls-remote` out of `repo` and attribute it.
///
/// The fourth server command, and the one no decomposition had covered. It
/// serves no objects at all, so [`StageRun::objects`] is 0 here **by
/// construction** — which is why this driver does not carry `fetch`'s
/// `objects_sent > 0` guard and instead asserts that refs were advertised. A
/// census that dropped `ls-remote` for having no object count would have
/// dropped the one command whose whole cost is the ref store.
pub fn ls_remote(repo: &std::path::Path) -> Result<StageRun> {
let instruments = install();
let kind =
stored_hash_kind(repo).with_context(|| format!("reading the hash kind of {repo:?}"))?;
let store =
crate::engine::open_repo(repo, kind).with_context(|| format!("opening {repo:?}"))?;
let git = store.git();
let counting = StageCounting::new(git.as_ref());
let format = match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
let upload = UploadPack::new(&counting, kind);
let mut input = std::io::BufReader::new(std::io::Cursor::new(ls_refs_request(format)));
let mut out: Vec<u8> = Vec::new();
gunnar_stage::reset();
gunnar_stage::enable();
let cpu0 = process_cpu_ns();
let summary = upload.serve_v2(&mut input, &mut out)?;
let cpu1 = process_cpu_ns();
gunnar_stage::disable();
let report = gunnar_stage::thread_report();
ensure!(
summary.ls_refs > 0,
"the decomposed ls-remote served NO ls-refs command at all"
);
ensure!(
out.len() > 4,
"the ls-remote advertised {} bytes, which is not a ref list",
out.len()
);
ensure!(
!report.is_empty(),
"the serve path recorded NO stages; see `fetch`"
);
Ok(shape(
&report,
&counting.into_reads(),
out.len() as u64,
0,
cpu1.saturating_sub(cpu0),
instruments,
has_bitmap(repo),
))
}
/// The same fetch served by `width` threads at once, with each worker's stages
/// kept apart and then summed.
///
/// Returns the per-request decomposition — every column divided by `width` —
/// so the 1-client and 32-client tables are directly comparable and a stage
/// whose *share* grows is visible as such. That is the localisation
/// `gunnar.concurrency` could not do: it measured 0.418 s of CPU per request at
/// 1 client and 1.063 s at 32 and had nowhere to put the difference.
pub fn concurrent_fetch(
repo: &std::path::Path,
want: &ObjectId,
haves: &[ObjectId],
width: usize,
) -> Result<StageRun> {
ensure!(width >= 1, "a concurrency width of {width} is not a run");
let instruments = install();
gunnar_stage::reset_global();
let mut wire = 0u64;
let mut objects = 0u64;
let reads: std::sync::Mutex<BTreeMap<String, (u64, u64)>> =
std::sync::Mutex::new(BTreeMap::new());
let start = std::sync::Barrier::new(width);
// BEFORE the workers, not after. Written the other way round first, and
// every worker then ran with recording off and flushed an empty table — the
// non-vacuity guard below is what caught it, which is the whole reason it
// is an `ensure!` and not a comment.
gunnar_stage::enable();
// **One store, shared**, exactly as the server has one. Opened per worker
// instead, `width = 2` DEADLOCKED: opening the engine repository twice opens the
// `.gunnar` Arrow log, and a second handle onto the same directory in the
// same process waits for the first one's lock forever. That is worth
// recording rather than working around silently, because it is also the
// faithful shape: sharing the odb, the pack cache and the ref store across
// connections is what the daemon does, and a per-connection store would
// have measured a cold cache 32 times and called it concurrency.
let kind = stored_hash_kind(repo)?;
let store = crate::engine::open_repo(repo, kind)?;
let shared_git = store.git();
let format = match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
let cpu0 = process_cpu_ns();
std::thread::scope(|scope| -> Result<()> {
let mut handles = Vec::with_capacity(width);
for _ in 0..width {
let start = &start;
let reads = &reads;
let git = shared_git.as_ref();
handles.push(scope.spawn(move || -> Result<(u64, u64)> {
let counting = StageCounting::new(git);
let request = fetch_request(want, haves, format);
let upload = UploadPack::new(&counting, kind);
let mut input = std::io::BufReader::new(std::io::Cursor::new(request));
let mut out: Vec<u8> = Vec::new();
gunnar_stage::reset();
// Every worker opened its own store and built its own request
// before this line. The barrier is what makes the measured
// window genuinely concurrent rather than a staggered ramp, and
// a staggered ramp is exactly the shape that hides a
// contention effect.
start.wait();
let summary = upload.serve_v2(&mut input, &mut out)?;
gunnar_stage::flush_thread();
let mut map = reads.lock().expect("the stage read map");
for (stage, (n, b)) in counting.into_reads() {
let e = map.entry(stage).or_insert((0, 0));
e.0 += n;
e.1 += b;
}
Ok((out.len() as u64, u64::from(summary.objects_sent)))
}));
}
for handle in handles {
let (bytes, sent) = handle.join().expect("a fetch worker panicked")?;
wire += bytes;
objects += sent;
}
Ok(())
})?;
let cpu1 = process_cpu_ns();
gunnar_stage::disable();
let mut report = gunnar_stage::global_report();
ensure!(
!report.is_empty(),
"no worker recorded a stage; see `fetch`"
);
ensure!(
objects > 0,
"the concurrent workers sent ZERO objects between them"
);
// Per request, so the table is the same shape at every width.
let divide = |v: u64| v / width as u64;
for tally in report.values_mut() {
tally.excl_wall_ns = divide(tally.excl_wall_ns);
tally.incl_wall_ns = divide(tally.incl_wall_ns);
tally.excl_user_ns = divide(tally.excl_user_ns);
tally.excl_sys_ns = divide(tally.excl_sys_ns);
tally.excl_alloc_count = divide(tally.excl_alloc_count);
tally.excl_alloc_bytes = divide(tally.excl_alloc_bytes);
tally.peak_growth_bytes = divide(tally.peak_growth_bytes);
tally.rss_growth_bytes = divide(tally.rss_growth_bytes);
}
let mut per_request = reads.into_inner().expect("the stage read map");
for value in per_request.values_mut() {
value.0 /= width as u64;
value.1 /= width as u64;
}
Ok(shape(
&report,
&per_request,
wire / width as u64,
objects / width as u64,
cpu1.saturating_sub(cpu0) / width as u64,
Instruments {
// Process-wide, and therefore not attributable at width > 1. Said
// out loud rather than published as a number that reads like one.
rss: instruments.rss && width == 1,
..instruments
},
has_bitmap(repo),
))
}
/// The packfile out of a v2 `packfile` section, demultiplexed off the sidebands.
pub fn demux_packfile(response: &[u8]) -> Result<Vec<u8>> {
let mut pack = Vec::new();
let mut cursor = response;
let mut in_packfile = false;
while cursor.len() >= 4 {
let len = usize::from_str_radix(std::str::from_utf8(&cursor[..4])?, 16)?;
if len < 4 {
cursor = &cursor[4..];
continue;
}
ensure!(
len <= cursor.len(),
"a pkt-line ran past the end of the response"
);
let payload = &cursor[4..len];
if in_packfile {
ensure!(!payload.is_empty(), "an empty band in the packfile section");
match payload[0] {
1 => pack.extend_from_slice(&payload[1..]),
3 => anyhow::bail!("band 3: {}", String::from_utf8_lossy(&payload[1..])),
_ => {}
}
} else if payload == b"packfile\n" {
in_packfile = true;
}
cursor = &cursor[len..];
}
ensure!(
pack.starts_with(b"PACK"),
"the demultiplexed bytes are not a packfile"
);
Ok(pack)
}
/// Serve **one** push into `into` and attribute it.
///
/// The packfile is the one a real `upload-pack` produced for the same object
/// set, taken off `source` — which is what a push carries, and is a corpus with
/// somebody else's delta chains in it rather than one this implementation just
/// computed for itself.
pub fn push(
source: &std::path::Path,
into: &std::path::Path,
tip: &ObjectId,
ref_name: &str,
) -> Result<StageRun> {
use gunnar_wire::receive_pack::{AllowAll, PackIngest, PackReceipt, ReceivePack};
let instruments = install();
let kind = stored_hash_kind(source)?;
// 1. What the client would have sent, produced by the real pack writer.
let pack = {
let store = crate::engine::open_repo(source, kind)?;
let git = store.git();
let format = match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
let upload = UploadPack::new(git.as_ref(), kind);
let mut input =
std::io::BufReader::new(std::io::Cursor::new(fetch_request(tip, &[], format)));
let mut out: Vec<u8> = Vec::new();
upload.serve_v2(&mut input, &mut out)?;
demux_packfile(&out)?
};
ensure!(
pack.len() > 1024,
"the pack to be pushed is {} bytes; a push of nothing decomposes into nothing",
pack.len()
);
// 2. The receiving side.
std::fs::create_dir_all(into)?;
let target = crate::engine::open_repo(into, kind)?;
let target_git = target.git();
// No `objects/pack` is created. The engine stores the pushed pack itself;
// an empty packfile directory is what a later reader uses to decide a
// repository is packfile-shaped, and this server never writes one.
/// The push, through the engine — the same `GitOps::put_pack` a real
/// `receive-pack` lands in, so the stage measures the shipping path rather
/// than a fixture that resembles it.
///
/// **`Arc<dyn GitOps>`, and not a concrete engine type.** This field held
/// `Arc<gunnar_znippy_engine::GitStore>` until 2026-08-10, and that one line is
/// most of why building *any* of the 49 arms — `pack_deflate` included —
/// required a sibling znippy checkout. The contract is the seam; the engine
/// arrives through [`crate::engine`].
///
/// ⚠️ **A CONTRACT GAP, recorded rather than papered over.** The deleted
/// `GitStorePackIngest::ingest` handed back `(pack_id, num_objects,
/// version)`. `GitOps::put_pack` hands back a `TxId`, whose three fields are
/// `pack_id: Option<u64>` (a dense *internal* id, not the pack trailer),
/// `extent` and `push_seq` — **none of the three things `PackReceipt`
/// needs**. So they are read back off the pack bytes below.
///
/// That is honest as far as it goes: object count and trailer are facts
/// about the bytes the client sent, not about what the store decided, and
/// reading them from the wire is the same thing `index_pack` does. What it
/// cannot do is report the store's own view — so if `receive-pack` ever
/// needs to answer "which pack, as the engine numbers them", that is a
/// `GitOps` question and belongs on the contract, not here.
struct Ingest {
store: std::sync::Arc<dyn gunnar_store::git_storage_trait::GitOps>,
hash_kind: gix_hash::Kind,
}
impl PackIngest for Ingest {
fn ingest(&self, pack: &mut dyn std::io::BufRead) -> gunnar_wire::Result<PackReceipt> {
// `put_pack` takes `&[u8]`: the whole pack, in memory. That is the
// contract's shape and not a choice made here — see the note above.
let mut bytes = Vec::new();
pack.read_to_end(&mut bytes).map_err(|e| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
"reading the pushed pack: {e}"
)))
})?;
let hash_len = self.hash_kind.len_in_bytes();
// 12-byte header (`PACK`, version, count) and a trailer of one hash.
// A pack shorter than both is not a short pack, it is not a pack.
if bytes.len() < 12 + hash_len || &bytes[..4] != b"PACK" {
return Err(gunnar_wire::Error::Store(gunnar_store::Error::backend(
"the pushed bytes are not a packfile",
)));
}
let pack_version = gunnar_wire::PackVersion::from_u32(u32::from_be_bytes([
bytes[4], bytes[5], bytes[6], bytes[7],
]));
// `u32`, not `usize`: `gunnar_wire::IndexedPack::num_objects` is a
// `u32`, which is the width the pack header itself carries.
let num_objects = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let pack_id =
gix_hash::ObjectId::try_from(&bytes[bytes.len() - hash_len..]).map_err(|_| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(
"the pushed pack's trailer is not an object id",
))
})?;
self.store.put_pack(&bytes).map_err(|e| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
"the engine refused the pushed pack: {e:#}"
)))
})?;
Ok(PackReceipt::without_object_set(gunnar_wire::IndexedPack {
num_objects,
pack_id,
// No `.idx` exists: the objects table replaced it.
index_id: gix_hash::ObjectId::null(self.hash_kind),
pack_version,
hash_kind: self.hash_kind,
index_path: None,
data_path: None,
keep_path: None,
// The engine was handed these bytes, so the count is exact and
// needs no file to stat — which is the point, since this arm
// writes none.
stored_bytes: Some(bytes.len() as u64),
}))
}
}
let null = kind.null();
let mut request = Vec::new();
let first = format!(
"{null} {tip} {ref_name}\0report-status side-band-64k object-format={}\n",
match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
}
);
request.extend(pkt(&first));
request.extend_from_slice(b"0000");
request.extend_from_slice(&pack);
let ingest = Ingest {
// The store the receiving side already opened, shared rather than
// reopened: a second handle onto the same directory in the same process
// waits on the first one's lock forever (see the `width = 2` deadlock
// note above). `RepoStore::git()` hands out the `Arc` for exactly this.
store: target_git.clone(),
hash_kind: kind,
};
let receive = ReceivePack::new(target_git.as_ref(), kind);
let mut input = std::io::BufReader::new(std::io::Cursor::new(request));
let mut out: Vec<u8> = Vec::new();
gunnar_stage::reset();
gunnar_stage::enable();
let cpu0 = process_cpu_ns();
let outcome = receive.serve(&mut input, &mut out, &ingest, &AllowAll)?;
let cpu1 = process_cpu_ns();
gunnar_stage::disable();
let report = gunnar_stage::thread_report();
let outcome = outcome.context("the push produced no outcome at all")?;
ensure!(
outcome.all_ok(),
"the decomposed push was refused: {:?}",
outcome.statuses
);
ensure!(
!report.is_empty(),
"the push recorded NO stages; see `fetch`"
);
let objects_in = outcome
.pack
.as_ref()
.map_or(0, |p| u64::from(p.num_objects));
ensure!(
objects_in > 0,
"the push indexed ZERO objects, so every fraction is a share of nothing"
);
Ok(shape(
&report,
&BTreeMap::new(),
pack.len() as u64,
objects_in,
cpu1.saturating_sub(cpu0),
instruments,
has_bitmap(into),
))
}
/// The repository's hash algorithm, read from its own config.
///
/// `gunnar_store::fs::stored_hash_kind` read it off the packfile engine's
/// on-disk layout and went with that engine. A repository served under the wrong
/// hash is not a degraded repository, it is a different one, so this asks the
/// repository rather than defaulting.
fn stored_hash_kind(repo: &std::path::Path) -> anyhow::Result<gix_hash::Kind> {
let config = std::fs::read_to_string(repo.join("config")).unwrap_or_default();
Ok(if config.to_ascii_lowercase().contains("sha256") {
gix_hash::Kind::Sha256
} else {
gix_hash::Kind::Sha1
})
}