crates/storage-git-gix/parked-gix-2/bench-gix/transportbuf-probe.rs
//! **What the transport's buffer costs, both arms, one process, one store.**
//!
//! ```text
//! transportbuf-probe <repo.git> [repo.git ...]
//! ```
//!
//! `gunnar-cli`'s `WireTransport::serve_blocking` wraps the stream it is given
//! in a `BufWriter` before handing it to `gunnar-wire`. Until 2026-08-05 that
//! was `BufWriter::new`, whose capacity is **8 KiB** — and a sideband packet is
//! **65 520**. So the buffer did not buffer: `write_sideband` writes a 4-byte
//! length, a 1-byte band and then the payload, and on the payload `BufWriter`
//! finds 65 515 bytes that do not fit, dumps the 5 bytes it is holding as a
//! write of its own, and passes the payload straight through. Two writes per
//! packet, **one of them five bytes**.
//!
//! That is not a syscall count on this path. `WireTransport` runs the state
//! machine on a `spawn_blocking` thread writing into a `SyncIoBridge` over a
//! `tokio::io::duplex`, so **one write is one hand-off**: a `block_on`, a waker,
//! and, whenever the reader is parked, a futex pair. Under `gunnar-http` the
//! reader is hyper's body poll; under `gunnar-ssh` it is the russh channel.
//!
//! # Why this is a probe and not a timing
//!
//! The quantity is **deterministic**. The two arms serve the same objects and
//! produce byte-identical output; the only thing that differs is how that
//! output was handed over. A wall clock on oden has run at load 3–270 and could
//! not resolve this at all — the live 16-client A/B on 2026-08-05 measured
//! −26% voluntary context switches per clone and could not separate the wall
//! times, which is exactly the shape of a real change hiding under a loaded
//! box. So this counts the hand-offs directly, off one store, with no socket
//! and no scheduler in the answer.
//!
//! # The counter that decides it, and the red arm
//!
//! `writes` is the applied output. Two guards make it impossible to report a
//! win having measured nothing:
//!
//! * the 8 KiB arm **must** exhibit a write of 8 bytes or fewer, or the item
//! this probe exists to measure is not present in that repository and no
//! ratio off it means anything;
//! * the two arms **must** produce the same digest over the same object count,
//! or they are not two framings of one answer but two different answers.
use anyhow::{bail, ensure, Context, Result};
use gix_hash::ObjectId;
use gunnar_bench::stages;
use gunnar_store::fs::FsStore;
use gunnar_wire::upload_pack::UploadPack;
use gunnar_wire::TRANSPORT_BUFFER_BYTES;
use std::io::Write;
/// The stream underneath the `BufWriter`: it remembers how it was called, not
/// what it was given.
///
/// The bytes are identical in both arms by construction, so a sink that
/// compared them would pass at every capacity. What differs is the **sequence
/// of writes**, and on this transport each one is a thread hand-off.
#[derive(Default)]
struct Handoffs {
writes: u64,
bytes: u64,
smallest: Option<usize>,
/// Writes of exactly five bytes: a 4-byte pkt-line length plus the band
/// byte, handed over **alone**. That is the defect itself — nothing in the
/// protocol asks for a 5-byte write, it is what `BufWriter` dumps when the
/// payload behind the header does not fit it — so counting them is the
/// difference between measuring this change and measuring a buffer size.
five_byte: u64,
/// FNV-1a over everything written, so the two arms can be proved to have
/// produced the same answer.
digest: u64,
}
impl Handoffs {
fn new() -> Handoffs {
Handoffs {
digest: 0xcbf2_9ce4_8422_2325,
..Handoffs::default()
}
}
}
impl Write for Handoffs {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.writes += 1;
self.bytes += data.len() as u64;
self.smallest = Some(self.smallest.map_or(data.len(), |m| m.min(data.len())));
if data.len() == 5 {
self.five_byte += 1;
}
for byte in data {
self.digest ^= u64::from(*byte);
self.digest = self.digest.wrapping_mul(0x0000_0100_0000_01b3);
}
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// `getrusage(RUSAGE_SELF)`: the whole process, every thread, in nanoseconds.
/// Reported beside the write count and **not** used to decide anything — a
/// scheduler figure off a loaded box is a statement about the box.
fn process_cpu_ns() -> 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_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)
}
struct Arm {
writes: u64,
bytes: u64,
smallest: usize,
five_byte: u64,
digest: u64,
objects: u64,
cpu_ns: u64,
}
/// One clone served through a `BufWriter` of `capacity`, exactly as
/// `WireTransport::serve_blocking` builds one.
fn serve(repo: &std::path::Path, want: &ObjectId, capacity: usize) -> Result<Arm> {
let kind = gunnar_store::fs::stored_hash_kind(repo)?;
let store = FsStore::open_or_init(repo, kind)?;
let objects = store.objects();
let refs = store.refs();
let format = match kind {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
let upload = UploadPack::new(&objects, &refs);
let mut input = std::io::BufReader::new(std::io::Cursor::new(stages::fetch_request(
want,
&[],
format,
)));
let mut sink = Handoffs::new();
let cpu0 = process_cpu_ns();
let summary = {
// The same two lines `serve_blocking` runs, and the only difference
// between the arms is the number on this one.
let mut out = std::io::BufWriter::with_capacity(capacity, &mut sink);
let summary = upload.serve_v2(&mut input, &mut out)?;
out.flush()?;
summary
};
let cpu_ns = process_cpu_ns().saturating_sub(cpu0);
ensure!(
summary.objects_sent > 0,
"{repo:?}: the probe served ZERO objects, and two empty answers are framed identically"
);
Ok(Arm {
writes: sink.writes,
bytes: sink.bytes,
smallest: sink.smallest.unwrap_or(0),
five_byte: sink.five_byte,
digest: sink.digest,
objects: u64::from(summary.objects_sent),
cpu_ns,
})
}
/// The capacity the transport used to have. Named here rather than imported,
/// because it is `std`'s `DEFAULT_BUF_SIZE` and not gunnar's to change — it is
/// the value this probe exists to measure against.
const OLD_CAPACITY: usize = 8 * 1024;
fn compare(repo: &std::path::Path) -> Result<()> {
let want = tip(repo)?;
let old = serve(repo, &want, OLD_CAPACITY)?;
let new = serve(repo, &want, TRANSPORT_BUFFER_BYTES)?;
// RED: no 5-byte write means `BufWriter` was never defeated here, so
// there is no item and the ratio below would be a ratio of nothing. A
// 4-byte write is NOT enough to establish it — a flush-pkt is four bytes
// and both arms emit those, so an arm counting "small writes" would find
// the item in a repository that has none.
ensure!(
old.five_byte > 0,
"{repo:?}: the {OLD_CAPACITY}-byte arm never handed over a bare 5-byte pkt-line header, \
so `BufWriter` was not defeated here and this repository has none of the item. Any \
ratio taken off it is a ratio of protocol chatter."
);
ensure!(
new.five_byte == 0,
"{repo:?}: the {TRANSPORT_BUFFER_BYTES}-byte arm still handed over {} bare pkt-line \
header(s) — the payload behind them is not fitting the buffer, which is the whole \
defect",
new.five_byte
);
// The two arms are two framings of ONE answer, or they are not comparable.
ensure!(
old.digest == new.digest && old.bytes == new.bytes && old.objects == new.objects,
"{repo:?}: the arms served different bytes — {} objects/{} bytes/{:016x} against {} \
objects/{} bytes/{:016x}. A buffer capacity cannot change the answer, so one of these \
arms is not serving what it says.",
old.objects,
old.bytes,
old.digest,
new.objects,
new.bytes,
new.digest
);
println!(
"{{\"repo\":{:?},\"objects\":{},\"bytes\":{},\
\"writes_8k\":{},\"writes_{}\":{},\"ratio\":{:.3},\
\"five_byte_8k\":{},\"five_byte_new\":{},\
\"smallest_8k\":{},\"smallest_new\":{},\
\"cpu_ns_8k\":{},\"cpu_ns_new\":{}}}",
repo.file_name().unwrap_or_default().to_string_lossy(),
old.objects,
old.bytes,
old.writes,
TRANSPORT_BUFFER_BYTES,
new.writes,
old.writes as f64 / new.writes.max(1) as f64,
old.five_byte,
new.five_byte,
old.smallest,
new.smallest,
old.cpu_ns,
new.cpu_ns,
);
eprintln!(
"{:<28} obj {:>7} wire {:>10} hand-offs {:>7} → {:>6} ({:.2}x) bare 5-byte headers \
{:>6} → {:>3}",
repo.file_name().unwrap_or_default().to_string_lossy(),
old.objects,
old.bytes,
old.writes,
new.writes,
old.writes as f64 / new.writes.max(1) as f64,
old.five_byte,
new.five_byte,
);
Ok(())
}
/// The tip to clone. A clone and not a fetch: the buffer is about the packfile,
/// and a negotiated fetch off a 40-commit `have` sends too little pack for the
/// framing to be the thing being measured.
fn tip(repo: &std::path::Path) -> Result<ObjectId> {
let out = std::process::Command::new("git")
.args(["-C", &repo.to_string_lossy(), "rev-list", "-n", "1", "HEAD"])
.output()
.context("running git rev-list")?;
ensure!(
out.status.success(),
"git rev-list failed in {repo:?}: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
let listing = String::from_utf8_lossy(&out.stdout);
let id = listing
.lines()
.find_map(|l| ObjectId::from_hex(l.trim().as_bytes()).ok())
.with_context(|| format!("{repo:?} has no commits"))?;
Ok(id)
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
bail!("transportbuf-probe <repo.git> [repo.git ...]");
}
let mut old_total = 0u64;
let mut new_total = 0u64;
for path in &args {
let repo = std::path::PathBuf::from(path);
compare(&repo)?;
let want = tip(&repo)?;
old_total += serve(&repo, &want, OLD_CAPACITY)?.writes;
new_total += serve(&repo, &want, TRANSPORT_BUFFER_BYTES)?.writes;
}
eprintln!(
"── {} repositories: {} hand-offs → {} ({:.2}x)",
args.len(),
old_total,
new_total,
old_total as f64 / new_total.max(1) as f64
);
Ok(())
}