crates/gunnar-perfbench/src/main.rs
//! **gunnar-perfbench**: Gunnar end to end, with the real clients (lane GUN-PERF, 2026-10-31).
//!
//! One run starts a release `gunnar serve` per engine (`GUNNAR_ENGINE=znippy`, then `gix`, from
//! one binary built with `--features engine-gix`) on a fresh data root, seeds it over SSH, and
//! measures:
//!
//! | arm | what |
//! |---|---|
//! | `push` | a small repository, a 256 MiB incompressible pack and git.git's history, over SSH and HTTP, into fresh repositories |
//! | `clone` | small, a 10 000-file tree and git.git (bare clones), over SSH and HTTP |
//! | `fetch_noop`, `fetch_inc` | an up-to-date fetch, and a fetch of one new commit on top of git.git |
//! | `ls_remote` | `git ls-remote` over SSH and HTTP (process start included) |
//! | `info_refs_v0/v2`, `ls_refs` | the raw requests on one keep-alive connection, c=1 latency and c=16 req/s |
//! | `lfs_upload`, `lfs_download` | `git lfs push --all` and `git lfs fetch --all` over HTTP, MB/s |
//! | `console_*`, `prerender` | the console's repository-page API as a guest, and the public-site prerendered page |
//! | `index_build_first_fetch` (family `index_build`), `clone_cold`, `fetch_cold` (family `cold`) | what a query costs to get there on the real repository: a restarted server's first clone, and its first fetch of a new commit (the reach-index build) |
//!
//! Every arm records p50/p99/mean wall time, MB/s where it moves bytes, the server's CPU time per
//! operation (utime + stime from `/proc/<pid>/stat`) and its peak RSS during the arm (`VmHWM`,
//! reset with `clear_refs` before each arm). Results go to `$GUNPERF_OUT` as JSON lines and to
//! stdout as a table.
//!
//! ```text
//! GUNPERF_GUNNAR=<target>/release/gunnar the server (build it with --features engine-gix)
//! GUNPERF_UI=<target>/release/gunnar-ui optional: the console (--features public-site)
//! GUNPERF_HISTORY=<path to a bare git.git> optional: the big-history repository
//! GUNPERF_WORK=<dir> scratch (wiped per engine)
//! GUNPERF_ENGINES=znippy,gix which engines
//! GUNPERF_ONLY=clone,info_refs optional: only these arm families
//! GUNPERF_REPS=1.0 repetition multiplier
//! GUNPERF_OUT=<file.jsonl> results
//! GUNPERF_HOLD=1 print the server pid and wait for ENTER before the arms (perf attach)
//! GUNPERF_HISTORY_NAME=git.git the real repository's name in the result (default git.git)
//! GUNPERF_RESULT=<file.json> optional: the machine-readable result (schema nordisk-bench-result/1)
//! GUNPERF_MACHINE=oden the machine's name in the result (default /etc/hostname)
//! GUNPERF_SOURCE=<commit> the gunnar commit the binary was built from
//! GUNPERF_BENCH=gunnar-perfbench the bench's name in the result (one README table per bench)
//! GUNPERF_PORT_BASE=23000 the first of the ports (engine i: base + 10 i .. + 3)
//! ```
//!
//! `GUNPERF_RESULT` is what the README's measured table is rendered from (README-BENCH:
//! `cargo xtask bench-readme run` sets all of these, `cargo xtask bench-readme write` renders
//! the table; xtask/src/bench_readme.rs has the schema). Every row of it names its dataset,
//! whose facts (objects, refs, pack MB, commits) the bench counts itself, and the load1 of the
//! machine at that step.
//!
//! It needs `git` (2.4x), `git-lfs` and `ssh` on PATH; the ci4 image carries all three.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
// ───────────────────────────── configuration ─────────────────────────────
struct Cfg {
gunnar: PathBuf,
ui: Option<PathBuf>,
history: Option<PathBuf>,
work: PathBuf,
engines: Vec<String>,
only: Option<Vec<String>>,
reps: f64,
out: PathBuf,
hold: bool,
lfs_mib: usize,
history_name: String,
result: Option<PathBuf>,
machine: String,
source: String,
bench: String,
}
fn env_path(k: &str) -> Option<PathBuf> {
std::env::var_os(k)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
impl Cfg {
fn from_env() -> Cfg {
let work = env_path("GUNPERF_WORK").unwrap_or_else(|| std::env::temp_dir().join("gunperf"));
Cfg {
gunnar: env_path("GUNPERF_GUNNAR").expect("GUNPERF_GUNNAR names the gunnar binary"),
ui: env_path("GUNPERF_UI"),
history: env_path("GUNPERF_HISTORY"),
out: env_path("GUNPERF_OUT").unwrap_or_else(|| work.join("results.jsonl")),
work,
engines: std::env::var("GUNPERF_ENGINES")
.unwrap_or_else(|_| "znippy,gix".into())
.split(',')
.map(str::to_string)
.collect(),
only: std::env::var("GUNPERF_ONLY")
.ok()
.filter(|s| !s.is_empty())
.map(|s| s.split(',').map(str::to_string).collect()),
reps: std::env::var("GUNPERF_REPS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1.0),
hold: std::env::var("GUNPERF_HOLD").is_ok_and(|v| v == "1"),
lfs_mib: std::env::var("GUNPERF_LFS_MIB")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(256),
history_name: std::env::var("GUNPERF_HISTORY_NAME")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "git.git".into()),
result: env_path("GUNPERF_RESULT"),
machine: std::env::var("GUNPERF_MACHINE")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
fs::read_to_string("/etc/hostname")
.unwrap_or_default()
.trim()
.to_string()
}),
source: std::env::var("GUNPERF_SOURCE").unwrap_or_default(),
bench: std::env::var("GUNPERF_BENCH")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "gunnar-perfbench".into()),
}
}
fn wants(&self, family: &str) -> bool {
self.only
.as_ref()
.is_none_or(|o| o.iter().any(|f| family.starts_with(f.as_str())))
}
fn n(&self, base: usize) -> usize {
((base as f64 * self.reps).round() as usize).max(1)
}
}
// ───────────────────────────── statistics ─────────────────────────────
#[derive(Default)]
struct Rec {
arm: String,
engine: String,
transport: String,
repo: String,
times: Vec<Duration>,
bytes: u64,
cpu_ms: f64,
client_cpu_ms: f64,
hwm_mib: f64,
anon_peak_mib: f64,
rss_mib: f64,
reqs_per_s: Option<f64>,
extra: BTreeMap<String, String>,
}
fn pct(v: &[Duration], p: usize) -> f64 {
if v.is_empty() {
return f64::NAN;
}
let mut s: Vec<Duration> = v.to_vec();
s.sort();
let i = ((s.len() * p).div_ceil(100))
.saturating_sub(1)
.min(s.len() - 1);
s[i].as_secs_f64() * 1e3
}
impl Rec {
fn mean_ms(&self) -> f64 {
if self.times.is_empty() {
return f64::NAN;
}
self.times.iter().map(|d| d.as_secs_f64()).sum::<f64>() / self.times.len() as f64 * 1e3
}
fn mb_s(&self) -> Option<f64> {
if self.bytes == 0 || self.times.is_empty() {
return None;
}
// bytes is per operation; MB/s at the median.
Some(self.bytes as f64 / 1e6 / (pct(&self.times, 50) / 1e3))
}
fn json(&self) -> String {
let mut s = String::from("{");
let f = |x: f64| {
if x.is_finite() {
format!("{x:.4}")
} else {
"null".into()
}
};
let _ = write!(
s,
"\"arm\":\"{}\",\"engine\":\"{}\",\"transport\":\"{}\",\"repo\":\"{}\",\"n\":{},\"p50_ms\":{},\"p99_ms\":{},\"mean_ms\":{},\"bytes\":{},\"mb_s\":{},\"cpu_ms_per_op\":{},\"client_cpu_ms_per_op\":{},\"hwm_mib\":{},\"anon_peak_mib\":{},\"rss_mib\":{},\"req_s\":{}",
self.arm,
self.engine,
self.transport,
self.repo,
self.times.len(),
f(pct(&self.times, 50)),
f(pct(&self.times, 99)),
f(self.mean_ms()),
self.bytes,
self.mb_s().map(f).unwrap_or("null".into()),
f(self.cpu_ms),
f(self.client_cpu_ms),
f(self.hwm_mib),
f(self.anon_peak_mib),
f(self.rss_mib),
self.reqs_per_s.map(f).unwrap_or("null".into()),
);
for (k, v) in &self.extra {
let _ = write!(
s,
",\"{k}\":\"{}\"",
v.replace('\\', "\\\\").replace('"', "\\\"")
);
}
s.push('}');
s
}
fn line(&self) -> String {
format!(
"{:<16} {:<7} {:<5} {:<12} n={:<4} p50 {:>10.2} ms p99 {:>10.2} ms {:>9} MB/s {:>9} req/s cpu {:>9.2} ms/op client {:>9.2} ms/op hwm {:>7.1} MiB anon {:>7.1} MiB",
self.arm,
self.engine,
self.transport,
self.repo,
self.times.len(),
pct(&self.times, 50),
pct(&self.times, 99),
self.mb_s().map(|x| format!("{x:.1}")).unwrap_or("-".into()),
self.reqs_per_s.map(|x| format!("{x:.0}")).unwrap_or("-".into()),
self.cpu_ms,
self.client_cpu_ms,
self.hwm_mib,
self.anon_peak_mib
)
}
}
// ───────────────────────────── the server process ─────────────────────────────
const CLK_TCK: f64 = 100.0;
/// Samples the server's anonymous RSS (`RssAnon`: the heap, not mapped files) every 10 ms
/// while an arm runs; `VmHWM` alone counts the mapped pack and archive pages too.
struct AnonSampler {
stop: Arc<AtomicBool>,
peak: Arc<AtomicU64>,
th: Option<std::thread::JoinHandle<()>>,
}
impl AnonSampler {
fn start(pid: u32) -> AnonSampler {
let stop = Arc::new(AtomicBool::new(false));
let peak = Arc::new(AtomicU64::new(0));
let (s2, p2) = (stop.clone(), peak.clone());
let th = std::thread::spawn(move || {
let path = format!("/proc/{pid}/status");
while !s2.load(Ordering::Relaxed) {
if let Ok(st) = fs::read_to_string(&path) {
if let Some(kib) = st
.lines()
.find(|l| l.starts_with("RssAnon:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse::<u64>().ok())
{
p2.fetch_max(kib, Ordering::Relaxed);
}
}
std::thread::sleep(Duration::from_millis(10));
}
});
AnonSampler {
stop,
peak,
th: Some(th),
}
}
fn stop_mib(mut self) -> f64 {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.th.take() {
let _ = t.join();
}
self.peak.load(Ordering::Relaxed) as f64 / 1024.0
}
}
/// CPU time of this process's waited-for children (the git clients): cutime + cstime.
fn client_cpu_ms() -> f64 {
let s = fs::read_to_string("/proc/self/stat").unwrap_or_default();
let rest = s.rsplit_once(')').map(|(_, r)| r).unwrap_or("");
let f: Vec<&str> = rest.split_whitespace().collect();
let cu: f64 = f.get(13).and_then(|v| v.parse().ok()).unwrap_or(0.0);
let cs: f64 = f.get(14).and_then(|v| v.parse().ok()).unwrap_or(0.0);
(cu + cs) / CLK_TCK * 1e3
}
struct Proc {
child: Child,
pid: u32,
stopped: bool,
}
impl Proc {
fn cpu_ms(&self) -> f64 {
let s = fs::read_to_string(format!("/proc/{}/stat", self.pid)).unwrap_or_default();
// The command name may hold spaces; the fields after the last ')' are fixed.
let rest = s.rsplit_once(')').map(|(_, r)| r).unwrap_or("");
let f: Vec<&str> = rest.split_whitespace().collect();
// rest[0] is field 3 (state); utime is field 14, stime 15.
let ut: f64 = f.get(11).and_then(|v| v.parse().ok()).unwrap_or(0.0);
let st: f64 = f.get(12).and_then(|v| v.parse().ok()).unwrap_or(0.0);
(ut + st) / CLK_TCK * 1e3
}
fn status_kib(&self, key: &str) -> f64 {
let s = fs::read_to_string(format!("/proc/{}/status", self.pid)).unwrap_or_default();
s.lines()
.find(|l| l.starts_with(key))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse().ok())
.unwrap_or(f64::NAN)
}
fn reset_hwm(&self) {
let _ = fs::write(format!("/proc/{}/clear_refs", self.pid), "5");
}
fn alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
}
impl Drop for Proc {
fn drop(&mut self) {
self.stop();
}
}
impl Proc {
fn stop(&mut self) {
if std::mem::replace(&mut self.stopped, true) {
return;
}
// Our own child only, by its pid.
let _ = Command::new("kill")
.arg("-TERM")
.arg(self.pid.to_string())
.status();
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Ok(Some(_)) = self.child.try_wait() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn wait_port(port: u16, what: &str, p: &mut Proc) {
let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return;
}
assert!(p.alive(), "{what} exited before it listened on {port}");
std::thread::sleep(Duration::from_millis(50));
}
panic!("{what} never listened on {port}");
}
// ───────────────────────────── commands ─────────────────────────────
struct Env {
home: PathBuf,
ssh_port: u16,
http_port: u16,
vars: Vec<(String, String)>,
}
impl Env {
fn cmd(&self, prog: &str) -> Command {
let mut c = Command::new(prog);
c.env("HOME", &self.home)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env(
"GIT_SSH_COMMAND",
format!(
"ssh -p {} -i {}/.ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ControlMaster=no",
self.ssh_port,
self.home.display()
),
);
for (k, v) in &self.vars {
c.env(k, v);
}
c
}
fn ssh_url(&self, repo: &str) -> String {
format!("ssh://git@127.0.0.1/bench/{repo}.git")
}
fn http_url(&self, repo: &str) -> String {
format!("http://127.0.0.1:{}/bench/{repo}.git", self.http_port)
}
fn url(&self, transport: &str, repo: &str) -> String {
if transport == "ssh" {
self.ssh_url(repo)
} else {
self.http_url(repo)
}
}
}
fn run(mut c: Command) -> String {
let what = format!("{c:?}");
let o = c
.stdin(Stdio::null())
.output()
.unwrap_or_else(|e| panic!("{what}: {e}"));
if !o.status.success() {
panic!(
"{what} failed: {}\n{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
}
String::from_utf8_lossy(&o.stdout).into_owned()
}
fn git(env: &Env, dir: Option<&Path>, args: &[&str]) -> String {
let mut c = env.cmd("git");
if let Some(d) = dir {
c.current_dir(d);
}
c.args(args);
run(c)
}
fn dir_bytes(p: &Path) -> u64 {
let mut n = 0;
if let Ok(rd) = fs::read_dir(p) {
for e in rd.flatten() {
let m = match e.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if m.is_dir() {
n += dir_bytes(&e.path());
} else {
n += m.len();
}
}
}
n
}
// ───────────────────────────── seed repositories ─────────────────────────────
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn fill(&mut self, b: &mut [u8]) {
for c in b.chunks_mut(8) {
let v = self.next().to_le_bytes();
c.copy_from_slice(&v[..c.len()]);
}
}
fn text(&mut self, n: usize) -> Vec<u8> {
const W: &[&str] = &[
"fn", "let", "mut", "self", "impl", "struct", "match", "return", "gunnar", "store",
"pack", "ref", "=", "{", "}", "(", ")", ";", "\n", " ",
];
let mut v = Vec::with_capacity(n + 16);
while v.len() < n {
v.extend_from_slice(W[(self.next() % W.len() as u64) as usize].as_bytes());
v.push(b' ');
}
v
}
}
/// Feed `git fast-import` in `dir` (a fresh non-bare repository).
fn fast_import(env: &Env, dir: &Path, build: impl FnOnce(&mut dyn Write)) {
fs::create_dir_all(dir).unwrap();
git(env, Some(dir), &["init", "-q", "-b", "main"]);
let mut c = env.cmd("git");
c.current_dir(dir)
.args(["fast-import", "--quiet"])
.stdin(Stdio::piped());
let mut ch = c.spawn().unwrap();
{
let mut w = io::BufWriter::with_capacity(1 << 20, ch.stdin.take().unwrap());
build(&mut w);
w.flush().unwrap();
}
assert!(
ch.wait().unwrap().success(),
"fast-import in {}",
dir.display()
);
}
fn fi_blob(w: &mut dyn Write, mark: usize, data: &[u8]) {
write!(w, "blob\nmark :{mark}\ndata {}\n", data.len()).unwrap();
w.write_all(data).unwrap();
w.write_all(b"\n").unwrap();
}
fn fi_commit(
w: &mut dyn Write,
mark: usize,
parent: Option<usize>,
t: u64,
msg: &str,
files: &[(String, usize)],
) {
write!(w, "commit refs/heads/main\nmark :{mark}\ncommitter bench <bench@mock.invalid> {t} +0000\ndata {}\n{msg}\n", msg.len()).unwrap();
if let Some(p) = parent {
writeln!(w, "from :{p}").unwrap();
}
for (path, m) in files {
writeln!(w, "M 100644 :{m} {path}").unwrap();
}
w.write_all(b"\n").unwrap();
}
fn seed_small(env: &Env, dir: &Path) {
let mut r = Rng(0x5eed_0001);
fast_import(env, dir, |w| {
let mut mark = 1;
let mut parent = None;
for c in 0..20 {
let mut files = Vec::new();
for f in 0..3 {
fi_blob(w, mark, &r.text(2000));
files.push((format!("src/f{}.rs", (c * 3 + f) % 10), mark));
mark += 1;
}
if c == 0 {
fi_blob(w, mark, b"# small\n\nA small repository.\n");
files.push(("README.md".into(), mark));
mark += 1;
}
fi_commit(
w,
mark,
parent,
1_700_000_000 + c as u64,
&format!("commit {c}"),
&files,
);
parent = Some(mark);
mark += 1;
}
});
}
fn seed_wide(env: &Env, dir: &Path) {
let mut r = Rng(0x5eed_0002);
fast_import(env, dir, |w| {
let mut mark = 1;
let mut files = Vec::new();
for i in 0..10_000 {
let n = 800 + (r.next() % 2400) as usize;
fi_blob(w, mark, &r.text(n));
files.push((format!("d{:03}/f{:05}.rs", i / 100, i), mark));
mark += 1;
}
fi_blob(w, mark, b"# wide\n\nTen thousand files.\n");
files.push(("README.md".into(), mark));
mark += 1;
fi_commit(w, mark, None, 1_700_000_000, "ten thousand files", &files);
let mut parent = mark;
mark += 1;
for c in 1..5 {
let mut ch = Vec::new();
for _ in 0..200 {
let i = (r.next() % 10_000) as usize;
fi_blob(w, mark, &r.text(1500));
ch.push((format!("d{:03}/f{:05}.rs", i / 100, i), mark));
mark += 1;
}
fi_commit(
w,
mark,
Some(parent),
1_700_000_000 + c,
&format!("change {c}"),
&ch,
);
parent = mark;
mark += 1;
}
});
}
fn seed_big(env: &Env, dir: &Path) {
let mut r = Rng(0x5eed_0003);
fast_import(env, dir, |w| {
let mut files = Vec::new();
let mut buf = vec![0u8; 8 << 20];
for i in 0..32 {
r.fill(&mut buf);
fi_blob(w, i + 1, &buf);
files.push((format!("blob{i:02}.bin"), i + 1));
}
fi_commit(
w,
100,
None,
1_700_000_000,
"256 MiB incompressible",
&files,
);
});
}
fn seed_lfs(env: &Env, dir: &Path, mib: usize) {
fs::create_dir_all(dir).unwrap();
git(env, Some(dir), &["init", "-q", "-b", "main"]);
git(env, Some(dir), &["lfs", "install", "--local"]);
git(env, Some(dir), &["lfs", "track", "*.bin"]);
let mut r = Rng(0x5eed_0004);
let big = mib / 16;
let mut buf = vec![0u8; big << 20];
for i in 0..16 {
r.fill(&mut buf);
fs::write(dir.join(format!("big{i:02}.bin")), &buf).unwrap();
}
let mut small = vec![0u8; 64 << 10];
for i in 0..64 {
r.fill(&mut small);
fs::write(dir.join(format!("small{i:02}.bin")), &small).unwrap();
}
git(env, Some(dir), &["add", "-A"]);
git(env, Some(dir), &["commit", "-q", "-m", "lfs objects"]);
}
// ───────────────────────────── a tiny HTTP/1.1 client ─────────────────────────────
struct Http {
port: u16,
conn: Option<BufReader<TcpStream>>,
}
struct Resp {
status: u16,
body: Vec<u8>,
}
impl Http {
fn new(port: u16) -> Http {
Http { port, conn: None }
}
fn req(
&mut self,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &[u8],
) -> io::Result<Resp> {
for attempt in 0..2 {
match self.try_req(method, path, headers, body) {
Ok(r) => return Ok(r),
Err(e) if attempt == 0 => {
self.conn = None;
let _ = e;
}
Err(e) => return Err(e),
}
}
unreachable!()
}
fn try_req(
&mut self,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &[u8],
) -> io::Result<Resp> {
if self.conn.is_none() {
let s = TcpStream::connect(("127.0.0.1", self.port))?;
s.set_nodelay(true)?;
s.set_read_timeout(Some(Duration::from_secs(60)))?;
self.conn = Some(BufReader::with_capacity(64 << 10, s));
}
let conn = self.conn.as_mut().unwrap();
let mut head = format!(
"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nUser-Agent: gunnar-perfbench\r\n",
self.port
);
for (k, v) in headers {
let _ = write!(head, "{k}: {v}\r\n");
}
if !body.is_empty() || method == "POST" {
let _ = write!(head, "Content-Length: {}\r\n", body.len());
}
head.push_str("\r\n");
let mut msg = head.into_bytes();
msg.extend_from_slice(body);
conn.get_mut().write_all(&msg)?;
let mut line = String::new();
if conn.read_line(&mut line)? == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "closed"));
}
let status: u16 = line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.ok_or_else(|| io::Error::other(format!("bad status line {line:?}")))?;
let mut len: Option<usize> = None;
let mut chunked = false;
let mut close = false;
loop {
line.clear();
conn.read_line(&mut line)?;
let l = line.trim_end();
if l.is_empty() {
break;
}
if let Some((k, v)) = l.split_once(':') {
let k = k.trim().to_ascii_lowercase();
let v = v.trim();
match k.as_str() {
"content-length" => len = v.parse().ok(),
"transfer-encoding" if v.eq_ignore_ascii_case("chunked") => chunked = true,
"connection" if v.eq_ignore_ascii_case("close") => close = true,
_ => {}
}
}
}
let mut out = Vec::new();
if method == "HEAD" || status == 204 || status == 304 {
} else if chunked {
loop {
line.clear();
conn.read_line(&mut line)?;
let n = usize::from_str_radix(line.trim().split(';').next().unwrap_or("0"), 16)
.map_err(io::Error::other)?;
if n == 0 {
// trailers
loop {
line.clear();
conn.read_line(&mut line)?;
if line.trim().is_empty() {
break;
}
}
break;
}
let at = out.len();
out.resize(at + n, 0);
conn.read_exact(&mut out[at..])?;
line.clear();
conn.read_line(&mut line)?;
}
} else if let Some(n) = len {
out.resize(n, 0);
conn.read_exact(&mut out)?;
} else {
conn.read_to_end(&mut out)?;
close = true;
}
if close {
self.conn = None;
}
Ok(Resp { status, body: out })
}
}
// ───────────────────────────── the run ─────────────────────────────
struct Bench<'a> {
cfg: &'a Cfg,
engine: String,
env: Env,
srv: Proc,
results: Vec<Rec>,
root: PathBuf,
grpc_port: u16,
serve_log: PathBuf,
}
impl Bench<'_> {
/// Stop the server and start a fresh process on the same data root: nothing of the last
/// process's memory (handles, caches, reach bitmaps) is left.
fn restart(&mut self) {
self.srv.stop();
self.srv = serve(
self.cfg,
&self.root,
&self.engine,
(self.env.ssh_port, self.env.http_port, self.grpc_port),
&self.serve_log,
);
}
/// What the server holds right now, as a row of its own: `VmRSS` (all) and `RssAnon`
/// (heap). `rss_idle` right after the start, `rss_held` after every arm: the difference is
/// what the server keeps between requests (the handle cache of open repositories with their
/// redb caches, and the reach bitmaps), not what one request needs. `n` = the repositories
/// the run created.
fn memory(&mut self, arm: &str, repo: &str, repos: usize) {
let r = Rec {
arm: arm.into(),
repo: repo.into(),
hwm_mib: self.srv.status_kib("VmRSS:") / 1024.0,
anon_peak_mib: self.srv.status_kib("RssAnon:") / 1024.0,
cpu_ms: f64::NAN,
client_cpu_ms: f64::NAN,
extra: [("repos".to_string(), repos.to_string())].into(),
..Default::default()
};
self.rec(r);
}
fn rec(&mut self, mut r: Rec) {
r.engine = self.engine.clone();
r.rss_mib = self.srv.status_kib("VmRSS:") / 1024.0;
let load = fs::read_to_string("/proc/loadavg").unwrap_or_default();
r.extra.insert(
"load1".into(),
load.split_whitespace().next().unwrap_or("").to_string(),
);
println!("{}", r.line());
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.cfg.out)
.unwrap();
writeln!(f, "{}", r.json()).unwrap();
self.results.push(r);
}
/// Time `n` runs of `op`, with the server's CPU and peak RSS around them.
fn arm(
&mut self,
arm: &str,
transport: &str,
repo: &str,
n: usize,
mut op: impl FnMut(&mut Self, usize) -> u64,
) {
self.srv.reset_hwm();
let mut r = Rec {
arm: arm.into(),
transport: transport.into(),
repo: repo.into(),
..Default::default()
};
let cpu0 = self.srv.cpu_ms();
let ccpu0 = client_cpu_ms();
let sampler = AnonSampler::start(self.srv.pid);
let mut bytes = 0;
for i in 0..n {
let t = Instant::now();
bytes = op(self, i);
r.times.push(t.elapsed());
}
r.cpu_ms = (self.srv.cpu_ms() - cpu0) / n as f64;
r.client_cpu_ms = (client_cpu_ms() - ccpu0) / n as f64;
r.anon_peak_mib = sampler.stop_mib();
r.hwm_mib = self.srv.status_kib("VmHWM:") / 1024.0;
r.bytes = bytes;
self.rec(r);
}
/// Requests on `c` keep-alive connections for `secs`: req/s, and latency at c=1 first.
#[allow(clippy::too_many_arguments)]
fn http_arm(
&mut self,
arm: &str,
repo: &str,
n1: usize,
path: &str,
method: &'static str,
headers: &[(&'static str, String)],
body: Vec<u8>,
conc: usize,
) {
self.http_arm_for(
arm,
repo,
n1,
path,
method,
headers,
body,
conc,
Duration::from_secs(3),
)
}
#[allow(clippy::too_many_arguments)]
fn http_arm_for(
&mut self,
arm: &str,
repo: &str,
n1: usize,
path: &str,
method: &'static str,
headers: &[(&'static str, String)],
body: Vec<u8>,
conc: usize,
secs: Duration,
) {
let port = self.env.http_port;
let hdrs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
// c=1 latency
self.srv.reset_hwm();
let mut h = Http::new(port);
let warm = h.req(method, path, &hdrs, &body).expect("http request");
let mut r = Rec {
arm: arm.into(),
transport: "http".into(),
repo: repo.into(),
..Default::default()
};
// the client here is this process's own threads, not a measured git: no client CPU
r.client_cpu_ms = f64::NAN;
r.extra.insert("status".into(), warm.status.to_string());
r.extra
.insert("body_bytes".into(), warm.body.len().to_string());
if warm.status >= 400 {
r.extra.insert(
"body_head".into(),
String::from_utf8_lossy(&warm.body[..warm.body.len().min(160)]).into_owned(),
);
}
let cpu0 = self.srv.cpu_ms();
let sampler = AnonSampler::start(self.srv.pid);
let mut statuses: BTreeMap<u16, usize> = BTreeMap::new();
for _ in 0..n1 {
let t = Instant::now();
let resp = h.req(method, path, &hdrs, &body).expect("http request");
r.times.push(t.elapsed());
*statuses.entry(resp.status).or_default() += 1;
}
// Every status the c=1 run saw, so a refusal mid-run is a number, not a panic.
let seen: Vec<String> = statuses.iter().map(|(k, v)| format!("{k}x{v}")).collect();
r.extra.insert("statuses".into(), seen.join(" "));
r.cpu_ms = (self.srv.cpu_ms() - cpu0) / n1 as f64;
// c=conc throughput
if conc > 1 {
let stop = Arc::new(AtomicBool::new(false));
let count = Arc::new(AtomicU64::new(0));
let refused = Arc::new(AtomicU64::new(0));
let mut th = Vec::new();
for _ in 0..conc {
let (stop, count, refused, path, body) = (
stop.clone(),
count.clone(),
refused.clone(),
path.to_string(),
body.clone(),
);
let headers: Vec<(String, String)> = headers
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect();
th.push(std::thread::spawn(move || {
let hd: Vec<(&str, &str)> = headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut h = Http::new(port);
while !stop.load(Ordering::Relaxed) {
match h.req(method, &path, &hd, &body) {
Ok(resp) if resp.status < 400 => {
count.fetch_add(1, Ordering::Relaxed);
}
Ok(_) | Err(_) => {
refused.fetch_add(1, Ordering::Relaxed);
}
}
}
}));
}
let t = Instant::now();
std::thread::sleep(secs);
stop.store(true, Ordering::Relaxed);
for t in th {
let _ = t.join();
}
r.reqs_per_s = Some(count.load(Ordering::Relaxed) as f64 / t.elapsed().as_secs_f64());
r.extra.insert("conc".into(), conc.to_string());
r.extra.insert(
"conc_refused".into(),
refused.load(Ordering::Relaxed).to_string(),
);
}
r.anon_peak_mib = sampler.stop_mib();
r.hwm_mib = self.srv.status_kib("VmHWM:") / 1024.0;
r.bytes = warm.body.len() as u64;
// MB/s is meaningless for small bodies; keep only req/s.
if r.bytes < (1 << 20) {
r.bytes = 0;
}
self.rec(r);
}
}
/// One new commit (one new blob in a one-file tree) on top of `origin/main`, made in the
/// work clone `w` and pushed over SSH; `w`'s `origin/main` follows it.
fn push_new_commit(b: &Bench, w: &Path, label: &str) {
let blob = format!("{label} {:?}\n", Instant::now());
let mut c = b.env.cmd("git");
c.current_dir(w)
.args(["hash-object", "-w", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped());
let mut ch = c.spawn().unwrap();
ch.stdin.take().unwrap().write_all(blob.as_bytes()).unwrap();
let oid = String::from_utf8(ch.wait_with_output().unwrap().stdout)
.unwrap()
.trim()
.to_string();
let tree = {
let mut c = b.env.cmd("git");
c.current_dir(w)
.args(["mktree"])
.stdin(Stdio::piped())
.stdout(Stdio::piped());
let mut ch = c.spawn().unwrap();
ch.stdin
.take()
.unwrap()
.write_all(format!("100644 blob {oid}\tnew-{label}.txt\n").as_bytes())
.unwrap();
String::from_utf8(ch.wait_with_output().unwrap().stdout)
.unwrap()
.trim()
.to_string()
};
let head = git(&b.env, Some(w), &["rev-parse", "origin/main"])
.trim()
.to_string();
let commit = git(
&b.env,
Some(w),
&[
"commit-tree",
&tree,
"-p",
&head,
"-m",
&format!("inc {label}"),
],
)
.trim()
.to_string();
git(
&b.env,
Some(w),
&["push", "-q", "origin", &format!("{commit}:refs/heads/main")],
);
git(&b.env, Some(w), &["fetch", "-q", "origin"]);
}
/// Start `gunnar serve` on `root` (a fresh process; its log is appended to `log`) and wait
/// until it listens on HTTP and SSH.
fn serve(cfg: &Cfg, root: &Path, engine: &str, ports: (u16, u16, u16), log: &Path) -> Proc {
let (ssh_port, http_port, grpc_port) = ports;
let log = fs::OpenOptions::new()
.create(true)
.append(true)
.open(log)
.unwrap();
let child = Command::new(&cfg.gunnar)
.env("GUNNAR_DATA_ROOT", root)
.env("GUNNAR_ENGINE", engine)
.env(
"RUST_LOG",
std::env::var("GUNPERF_SERVER_LOG").unwrap_or_else(|_| "warn".into()),
)
.args([
"serve",
"--ssh-bind",
&format!("127.0.0.1:{ssh_port}"),
"--http-bind",
&format!("127.0.0.1:{http_port}"),
])
.args([
"--grpc-bind",
&format!("127.0.0.1:{grpc_port}"),
"--http-allow-cleartext-credentials",
])
.stdout(log.try_clone().unwrap())
.stderr(log)
.spawn()
.expect("gunnar serve");
let pid = child.id();
let mut srv = Proc {
child,
pid,
stopped: false,
};
wait_port(http_port, "gunnar serve", &mut srv);
wait_port(ssh_port, "gunnar serve", &mut srv);
srv
}
fn admin(cfg: &Cfg, root: &Path, args: &[&str]) -> String {
let mut c = Command::new(&cfg.gunnar);
c.env("GUNNAR_DATA_ROOT", root)
.env("RUST_LOG", "error")
.args(args);
run(c)
}
fn pkt(s: &str) -> String {
format!("{:04x}{s}", s.len() + 4)
}
fn main() {
let cfg = Cfg::from_env();
fs::create_dir_all(&cfg.work).unwrap();
if let Some(d) = cfg.out.parent() {
fs::create_dir_all(d).unwrap();
}
let _ = fs::remove_file(&cfg.out);
let started = utc_now();
let load_before = load1();
let mut all = Vec::new();
for (ei, engine) in cfg.engines.clone().iter().enumerate() {
all.extend(run_engine(&cfg, engine, ei as u16));
}
println!("\n{} records → {}", all.len(), cfg.out.display());
if let Some(path) = &cfg.result {
let doc = result_json(&cfg, &all, &started, load_before);
if let Some(d) = path.parent() {
fs::create_dir_all(d).unwrap();
}
fs::write(path, doc).unwrap();
println!("result → {}", path.display());
}
}
// ───────────────────────────── the result file ─────────────────────────────
fn load1() -> f64 {
fs::read_to_string("/proc/loadavg")
.unwrap_or_default()
.split_whitespace()
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(f64::NAN)
}
/// Now, UTC, as `YYYY-MM-DDTHH:MM:SSZ`, from the clock alone.
fn utc_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
let z = secs.div_euclid(86_400) + 719_468;
let tod = secs.rem_euclid(86_400);
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
tod / 3600,
tod / 60 % 60,
tod % 60
)
}
fn jstr(s: &str) -> String {
let mut o = String::from("\"");
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
'\n' => o.push_str("\\n"),
c if (c as u32) < 0x20 => {
let _ = write!(o, "\\u{:04x}", c as u32);
}
c => o.push(c),
}
}
o.push('"');
o
}
fn jnum(x: f64) -> String {
if x.is_finite() {
let v = format!("{x:.4}");
let v = v.trim_end_matches('0').trim_end_matches('.');
if v.is_empty() || v == "-" {
"0".into()
} else {
v.to_string()
}
} else {
"null".into()
}
}
/// The first line a client prints for its version (`git --version`, `ssh -V` on stderr).
fn version_of(prog: &str, args: &[&str]) -> String {
Command::new(prog)
.args(args)
.stdin(Stdio::null())
.output()
.ok()
.map(|o| {
let mut t = String::from_utf8_lossy(&o.stdout).into_owned();
t.push_str(&String::from_utf8_lossy(&o.stderr));
t.lines().next().unwrap_or("").trim().to_string()
})
.unwrap_or_default()
}
/// A repository's size: (objects, refs, pack MB, commits), counted by git itself; `served`
/// names the refs the bench pushes (none: all of them).
fn repo_facts(dir: &Path, served: &[&str]) -> (f64, f64, f64, f64) {
let g = |args: &[&str]| -> String {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.stdin(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
};
let co = g(&["count-objects", "-v"]);
let field = |k: &str| -> f64 {
co.lines()
.find_map(|l| l.strip_prefix(k))
.and_then(|v| v.trim().parse::<f64>().ok())
.unwrap_or(f64::NAN)
};
let objects = field("count:") + field("in-pack:");
// size-pack and size are KiB; MB here is 10^6 bytes
let pack_mb = (field("size-pack:") + field("size:")) * 1024.0 / 1e6;
let mut fer = vec!["for-each-ref", "--format=x"];
fer.extend_from_slice(served);
let refs = g(&fer).lines().count() as f64;
let mut rl = vec!["rev-list", "--count"];
if served.is_empty() {
rl.push("--all");
} else {
rl.push("refs/heads/master");
rl.push("--tags");
}
let commits = g(&rl).trim().parse().unwrap_or(f64::NAN);
(objects, refs, pack_mb, commits)
}
fn kind_of(arm: &str) -> &'static str {
match arm {
"push" | "clone_cold" | "fetch_cold" | "fetch_inc" | "index_build_first_fetch" => "cost",
"rss_idle" | "rss_held" => "memory",
_ => "query",
}
}
/// The machine-readable result: schema `nordisk-bench-result/1` (xtask/src/bench_readme.rs).
fn result_json(cfg: &Cfg, all: &[Rec], started: &str, load_before: f64) -> String {
let ended = utc_now();
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(0);
let ram_gib = fs::read_to_string("/proc/meminfo")
.unwrap_or_default()
.lines()
.find_map(|l| l.strip_prefix("MemTotal:"))
.and_then(|v| v.split_whitespace().next())
.and_then(|v| v.parse::<f64>().ok())
.map(|kib| kib / 1024.0 / 1024.0)
.unwrap_or(f64::NAN);
let loads: Vec<f64> = all
.iter()
.filter_map(|r| r.extra.get("load1").and_then(|v| v.parse::<f64>().ok()))
.collect();
let lmin = loads.iter().cloned().fold(f64::INFINITY, f64::min);
let lmax = loads.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let name_of = |repo: &str| {
if repo == "history" {
cfg.history_name.clone()
} else {
repo.to_string()
}
};
// the datasets: the real repository, then the seeds the rows name
let mut datasets = Vec::new();
let src = cfg.work.join("src");
let mut seen: Vec<String> = Vec::new();
for r in all {
if !seen.contains(&r.repo) {
seen.push(r.repo.clone());
}
}
for repo in &seen {
let (real, what, dir) = match repo.as_str() {
"history" => (
true,
format!(
"{}, as served: without its commits carrying an AI trailer and the refs over them (Gunnar's ai-trailers gate refuses those)",
cfg.history_name
),
cfg.history.clone(),
),
"small" => (false, "20 commits of generated text".into(), Some(src.join("small"))),
"wide" => (false, "10 000 files in one tree, 5 commits".into(), Some(src.join("wide"))),
"big" => (false, "32 incompressible 8 MiB blobs, one commit".into(), Some(src.join("big"))),
"lfs" => (false, format!("{} MiB of LFS objects", cfg.lfs_mib), Some(src.join("lfs"))),
other => (false, other.to_string(), None),
};
let served: &[&str] = if repo == "history" {
&["refs/heads/master", "refs/tags"]
} else {
&[]
};
let (o, refs, mb, c) = dir.as_deref().map(|d| repo_facts(d, served)).unwrap_or((
f64::NAN,
f64::NAN,
f64::NAN,
f64::NAN,
));
datasets.push(format!(
" {{\"name\": {}, \"real\": {real}, \"what\": {}, \"objects\": {}, \"refs\": {}, \"pack_mb\": {}, \"commits\": {}}}",
jstr(&name_of(repo)),
jstr(&what),
jnum(o),
jnum(refs),
jnum(mb),
jnum(c)
));
}
let mut rows = Vec::new();
for r in all {
let n = r
.extra
.get("repos")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(r.times.len());
rows.push(format!(
" {{\"dataset\": {}, \"engine\": {}, \"op\": {}, \"transport\": {}, \"kind\": {}, \"n\": {n}, \"p50_ms\": {}, \"p99_ms\": {}, \"mean_ms\": {}, \"server_cpu_ms\": {}, \"client_cpu_ms\": {}, \"peak_rss_mib\": {}, \"peak_heap_mib\": {}, \"mb_s\": {}, \"req_s\": {}, \"load1\": {}}}",
jstr(&name_of(&r.repo)),
jstr(&r.engine),
jstr(&r.arm),
jstr(&r.transport),
jstr(kind_of(&r.arm)),
jnum(pct(&r.times, 50)),
jnum(pct(&r.times, 99)),
jnum(r.mean_ms()),
jnum(r.cpu_ms),
jnum(r.client_cpu_ms),
jnum(r.hwm_mib),
jnum(if r.anon_peak_mib > 0.0 { r.anon_peak_mib } else { f64::NAN }),
jnum(r.mb_s().unwrap_or(f64::NAN)),
jnum(r.reqs_per_s.unwrap_or(f64::NAN)),
r.extra.get("load1").map(String::as_str).filter(|v| !v.is_empty()).unwrap_or("null"),
));
}
let clients = [
version_of("git", &["--version"]).replace("git version ", "git "),
version_of("ssh", &["-V"]),
version_of("git", &["lfs", "version"]),
]
.iter()
.filter(|v| !v.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(", ");
format!(
"{{\n \"schema\": \"nordisk-bench-result/1\",\n \"product\": \"gunnar\",\n \"bench\": {},\n \"source\": {{\"repo\": \"gunnar\", \"commit\": {}}},\n \"date\": {},\n \"started_utc\": {},\n \"ended_utc\": {},\n \"machine\": {{\"name\": {}, \"threads\": {threads}, \"ram_gib\": {}}},\n \"load\": {{\"before\": {}, \"min\": {}, \"max\": {}}},\n \"notes\": {},\n \"datasets\": [\n{}\n ],\n \"rows\": [\n{}\n ]\n}}\n",
jstr(&cfg.bench),
jstr(&cfg.source),
jstr(&started[..10]),
jstr(started),
jstr(&ended),
jstr(&cfg.machine),
jnum(ram_gib.round()),
jnum(load_before),
jnum(lmin),
jnum(lmax),
jstr(&format!(
"Real clients: {clients}. One release build with both engines (`--features engine-gix`), the engine picked per run by `GUNNAR_ENGINE`"
)),
datasets.join(",\n"),
rows.join(",\n"),
)
}
fn run_engine(cfg: &Cfg, engine: &str, ei: u16) -> Vec<Rec> {
let base = cfg.work.join(engine);
let _ = fs::remove_dir_all(&base);
let root = base.join("root");
let home = base.join("home");
fs::create_dir_all(home.join(".ssh")).unwrap();
let port_base: u16 = std::env::var("GUNPERF_PORT_BASE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(23000);
let ssh_port = port_base + ei * 10;
let http_port = port_base + 1 + ei * 10;
let grpc_port = port_base + 2 + ei * 10;
let ui_port = port_base + 3 + ei * 10;
// Another bench on the same box holds the same ports: say so, never measure its server.
for p in [ssh_port, http_port, grpc_port, ui_port] {
assert!(
std::net::TcpListener::bind(("127.0.0.1", p)).is_ok(),
"127.0.0.1:{p} is taken (another bench on this box?): set GUNPERF_PORT_BASE"
);
}
let env = Env {
home: home.clone(),
ssh_port,
http_port,
vars: vec![],
};
run({
let mut c = Command::new("ssh-keygen");
c.args(["-q", "-t", "ed25519", "-N", ""])
.arg("-f")
.arg(home.join(".ssh/id_ed25519"));
c
});
git(&env, None, &["config", "--global", "user.name", "bench"]);
git(
&env,
None,
&["config", "--global", "user.email", "bench@mock.invalid"],
);
git(
&env,
None,
&["config", "--global", "init.defaultBranch", "main"],
);
git(
&env,
None,
&["config", "--global", "credential.helper", "store"],
);
git(
&env,
None,
&["config", "--global", "lfs.locksverify", "false"],
);
git(&env, None, &["config", "--global", "protocol.version", "2"]);
// The server's principal, key, token and repositories (offline, before it serves).
admin(cfg, &root, &["namespace", "create", "bench"]);
admin(cfg, &root, &["principal", "add", "alice", "--kind", "user"]);
let pubkey = fs::read_to_string(home.join(".ssh/id_ed25519.pub")).unwrap();
admin(cfg, &root, &["key", "add", "alice", pubkey.trim()]);
admin(
cfg,
&root,
&["grant", "--namespace", "bench", "alice", "admin"],
);
admin(cfg, &root, &["grant", "--server", "alice", "admin"]);
let tok_out = admin(cfg, &root, &["token", "add", "alice", "--label", "bench"]);
let token = tok_out
.split_whitespace()
.find(|w| w.starts_with("gunnar_pat_"))
.expect("a token")
.to_string();
fs::write(
home.join(".git-credentials"),
format!("http://alice:{token}@127.0.0.1:{http_port}\n"),
)
.unwrap();
// The console's guest: a principal with NO grants, its key where gunnar-ui looks for it.
let ui_dir = base.join("ui");
fs::create_dir_all(&ui_dir).unwrap();
run({
let mut c = Command::new("ssh-keygen");
c.args(["-q", "-t", "ed25519", "-N", ""])
.arg("-f")
.arg(ui_dir.join("guest"));
c
});
admin(
cfg,
&root,
&["principal", "add", "console-guest", "--kind", "machine"],
);
let guest_pub = fs::read_to_string(ui_dir.join("guest.pub")).unwrap();
admin(
cfg,
&root,
&["key", "add", "console-guest", guest_pub.trim()],
);
let push_reps = cfg.n(3);
let mut repos: Vec<String> = ["small", "wide", "history", "big", "lfs"]
.iter()
.map(|s| s.to_string())
.collect();
for kind in ["small", "big", "history"] {
for t in ["ssh", "http"] {
let reps = if kind == "history" { 1 } else { push_reps };
for i in 0..reps {
repos.push(format!("p-{kind}-{t}-{i}"));
}
}
}
for i in 0..push_reps {
repos.push(format!("lfsup-{i}"));
}
for r in &repos {
admin(
cfg,
&root,
&[
"repo",
"create",
&format!("bench/{r}"),
"--hash",
"sha1",
"--public",
"--default-branch",
"main",
],
);
}
let serve_log = base.join("serve.log");
let srv = serve(
cfg,
&root,
engine,
(ssh_port, http_port, grpc_port),
&serve_log,
);
let pid = srv.pid;
println!("\n== engine {engine}: gunnar serve pid {pid}, ssh {ssh_port}, http {http_port}, grpc {grpc_port}; idle rss {:.1} MiB", srv.status_kib("VmRSS:") / 1024.0);
// Seeds, once per run (shared by the engines).
let src = cfg.work.join("src");
let seed = |name: &str, f: &dyn Fn(&Path)| {
let d = src.join(name);
if !d.join(".git").exists() {
let t = Instant::now();
let _ = fs::remove_dir_all(&d);
f(&d);
println!("seeded {name} in {:.1} s", t.elapsed().as_secs_f64());
}
d
};
let small = seed("small", &|d| seed_small(&env, d));
let wide = seed("wide", &|d| seed_wide(&env, d));
let big = seed("big", &|d| seed_big(&env, d));
let lfs = seed("lfs", &|d| seed_lfs(&env, d, cfg.lfs_mib));
let history = cfg.history.clone();
let mut b = Bench {
cfg,
engine: engine.to_string(),
env,
srv,
results: Vec::new(),
root: root.clone(),
grpc_port,
serve_log,
};
let held_repo = if history.is_some() {
"history"
} else {
"small"
};
b.memory("rss_idle", held_repo, repos.len());
if cfg.hold {
println!("HOLD: server pid {pid}; press ENTER to start the arms");
let mut s = String::new();
let _ = io::stdin().read_line(&mut s);
}
// ── push: seed the served repositories, timed ──
// `pack.window=0`: the big seed is 32 incompressible 8 MiB blobs, and git's delta search over
// them costs the CLIENT ~49 s of CPU per push (measured); the arm is about the server.
let push_one = |b: &Bench, from: &Path, url: &str| {
git(
&b.env,
Some(from),
&[
"-c",
"pack.window=0",
"push",
"-q",
url,
"+refs/heads/main:refs/heads/main",
],
);
};
if cfg.wants("push") {
for (kind, from) in [("small", &small), ("big", &big)] {
let bytes = dir_bytes(&from.join(".git/objects"));
for t in ["ssh", "http"] {
b.arm("push", t, kind, push_reps, |b, i| {
let url = b.env.url(t, &format!("p-{kind}-{t}-{i}"));
push_one(b, from, &url);
bytes
});
}
}
if let Some(h) = &history {
let bytes = dir_bytes(&h.join("objects"));
for t in ["ssh", "http"] {
b.arm("push", t, "history", 1, |b, _| {
let url = b.env.url(t, &format!("p-history-{t}-0"));
git(
&b.env,
Some(h),
&[
"push",
"-q",
&url,
"+refs/heads/master:refs/heads/main",
"+refs/tags/*:refs/tags/*",
],
);
bytes
});
}
}
}
// The served set (untimed when the push family is off; the push arm wrote its own copies).
let t = Instant::now();
push_one(&b, &small, &b.env.ssh_url("small"));
push_one(&b, &wide, &b.env.ssh_url("wide"));
if let Some(h) = &history {
git(
&b.env,
Some(h),
&[
"push",
"-q",
&b.env.ssh_url("history"),
"+refs/heads/master:refs/heads/main",
"+refs/tags/*:refs/tags/*",
],
);
}
println!("served set pushed in {:.1} s", t.elapsed().as_secs_f64());
let clones = b.cfg.work.join(engine).join("clones");
// ── the index build, before anything else asks the server ──
// The client's copies come from the seed on disk, not from the server, so the timed fetch
// is the first select the served repository ever sees: znippy-plugin-git builds its reach
// index there (and persists it, from 0.2.3 on). A restart first: nothing in memory.
if cfg.wants("index_build") {
if let Some(h) = &history {
let repo = "history";
let d = clones.join("ib-fetch");
let w = clones.join("w-ib");
for p in [&d, &w] {
let _ = fs::remove_dir_all(p);
}
let hs = h.to_str().unwrap();
git(
&b.env,
None,
&["clone", "-q", "--bare", hs, d.to_str().unwrap()],
);
git(
&b.env,
Some(&d),
&["remote", "set-url", "origin", &b.env.http_url(repo)],
);
git(
&b.env,
None,
&["clone", "-q", "--no-checkout", hs, w.to_str().unwrap()],
);
git(
&b.env,
Some(&w),
&["remote", "set-url", "origin", &b.env.ssh_url(repo)],
);
// the served main is the seed's master
git(
&b.env,
Some(&w),
&[
"update-ref",
"refs/remotes/origin/main",
"refs/remotes/origin/master",
],
);
b.restart();
push_new_commit(&b, &w, "index-build");
b.arm("index_build_first_fetch", "http", repo, 1, |b, _| {
git(
&b.env,
Some(&d),
&["fetch", "-q", "origin", "+refs/heads/*:refs/heads/*"],
);
0
});
}
}
let mut served: Vec<(&str, usize)> = vec![("small", cfg.n(20)), ("wide", cfg.n(5))];
if history.is_some() {
served.push(("history", cfg.n(3)));
}
// ── clone ──
if cfg.wants("clone") {
for (repo, n) in &served {
for t in ["ssh", "http"] {
b.arm("clone", t, repo, *n, |b, i| {
let d = clones.join(format!("c-{repo}-{t}-{i}"));
let _ = fs::remove_dir_all(&d);
git(
&b.env,
None,
&[
"clone",
"-q",
"--bare",
&b.env.url(t, repo),
d.to_str().unwrap(),
],
);
let n = dir_bytes(&d.join("objects"));
let _ = fs::remove_dir_all(&d);
n
});
}
}
}
// ── fetch: a no-op, then one new commit on top ──
if cfg.wants("fetch") {
for (repo, _) in &served {
for t in ["ssh", "http"] {
let d = clones.join(format!("f-{repo}-{t}"));
let _ = fs::remove_dir_all(&d);
git(
&b.env,
None,
&[
"clone",
"-q",
"--bare",
&b.env.url(t, repo),
d.to_str().unwrap(),
],
);
b.arm("fetch_noop", t, repo, cfg.n(20), |b, _| {
git(
&b.env,
Some(&d),
&["fetch", "-q", "origin", "+refs/heads/*:refs/heads/*"],
);
0
});
}
}
// one new commit, pushed over SSH from a work clone, then fetched
for (repo, _) in &served {
let w = clones.join(format!("w-{repo}"));
let _ = fs::remove_dir_all(&w);
git(
&b.env,
None,
&[
"clone",
"-q",
"--no-checkout",
&b.env.ssh_url(repo),
w.to_str().unwrap(),
],
);
for t in ["ssh", "http"] {
let d = clones.join(format!("f-{repo}-{t}"));
let n = cfg.n(10);
let mut times = Vec::new();
b.srv.reset_hwm();
let mut cpu = 0.0;
let ccpu = client_cpu_ms;
let mut client = 0.0;
let sampler = AnonSampler::start(b.srv.pid);
for i in 0..n {
push_new_commit(&b, &w, &format!("{t}-{i}"));
let c0 = b.srv.cpu_ms();
let cc0 = ccpu();
let tt = Instant::now();
git(
&b.env,
Some(&d),
&["fetch", "-q", "origin", "+refs/heads/*:refs/heads/*"],
);
times.push(tt.elapsed());
cpu += b.srv.cpu_ms() - c0;
client += ccpu() - cc0;
}
let r = Rec {
arm: "fetch_inc".into(),
transport: t.into(),
repo: repo.to_string(),
times,
cpu_ms: cpu / n as f64,
client_cpu_ms: client / n as f64,
anon_peak_mib: sampler.stop_mib(),
hwm_mib: b.srv.status_kib("VmHWM:") / 1024.0,
..Default::default()
};
b.rec(r);
}
}
}
// ── cold: what a query costs to get there, on the real repository ──
// A fresh server process holds no handles, caches or reach bitmaps. Its first clone, and
// its first push of one commit with the fetch of it (the select builds the reach index),
// are the price every fast query after them already paid.
if cfg.wants("cold") && history.is_some() {
let repo = "history";
let d = clones.join("cold-fetch");
let w = clones.join("w-cold");
for p in [&d, &w] {
let _ = fs::remove_dir_all(p);
}
// a current client clone and a work clone, untimed, before the restarts
git(
&b.env,
None,
&[
"clone",
"-q",
"--bare",
&b.env.http_url(repo),
d.to_str().unwrap(),
],
);
git(
&b.env,
None,
&[
"clone",
"-q",
"--no-checkout",
&b.env.ssh_url(repo),
w.to_str().unwrap(),
],
);
b.restart();
b.arm("clone_cold", "http", repo, 1, |b, _| {
let c = clones.join("c-cold");
let _ = fs::remove_dir_all(&c);
git(
&b.env,
None,
&[
"clone",
"-q",
"--bare",
&b.env.http_url(repo),
c.to_str().unwrap(),
],
);
let n = dir_bytes(&c.join("objects"));
let _ = fs::remove_dir_all(&c);
n
});
b.restart();
b.arm("fetch_cold", "http", repo, 1, |b, _| {
push_new_commit(b, &w, "cold");
git(
&b.env,
Some(&d),
&["fetch", "-q", "origin", "+refs/heads/*:refs/heads/*"],
);
0
});
}
// ── ls-remote ──
if cfg.wants("ls_remote") {
for (repo, _) in &served {
for t in ["ssh", "http"] {
b.arm("ls_remote", t, repo, cfg.n(50), |b, _| {
git(&b.env, None, &["ls-remote", &b.env.url(t, repo)]);
0
});
}
}
}
// ── info/refs and ls-refs, raw ──
if cfg.wants("info_refs") || cfg.wants("ls_refs") {
for (repo, _) in &served {
let path = format!("/bench/{repo}.git/info/refs?service=git-upload-pack");
if cfg.wants("info_refs") {
b.http_arm(
"info_refs_v0",
repo,
cfg.n(1000),
&path,
"GET",
&[],
vec![],
16,
);
b.http_arm(
"info_refs_v2",
repo,
cfg.n(1000),
&path,
"GET",
&[("Git-Protocol", "version=2".into())],
vec![],
16,
);
}
if cfg.wants("ls_refs") {
let body = format!(
"{}{}0001{}{}{}0000",
pkt("command=ls-refs\n"),
pkt("agent=git/2.47.3\n"),
pkt("peel\n"),
pkt("symrefs\n"),
pkt("ref-prefix HEAD\n")
+ &pkt("ref-prefix refs/heads/")
+ &pkt("ref-prefix refs/tags/")
);
b.http_arm(
"ls_refs",
repo,
cfg.n(1000),
&format!("/bench/{repo}.git/git-upload-pack"),
"POST",
&[
("Git-Protocol", "version=2".into()),
(
"Content-Type",
"application/x-git-upload-pack-request".into(),
),
("Accept", "application/x-git-upload-pack-result".into()),
],
body.into_bytes(),
16,
);
}
}
}
// ── LFS over HTTP ──
if cfg.wants("lfs") {
let bytes = {
let mut n = 0u64;
for e in fs::read_dir(&lfs).unwrap().flatten() {
if e.path().extension().is_some_and(|x| x == "bin") {
n += e.metadata().unwrap().len();
}
}
n
};
b.arm("lfs_upload", "http", "lfs", push_reps, |b, i| {
let url = b.env.http_url(&format!("lfsup-{i}"));
git(&b.env, Some(&lfs), &["lfs", "push", "--all", &url]);
bytes
});
// the served copy: objects and refs
git(
&b.env,
Some(&lfs),
&[
"push",
"-q",
&b.env.http_url("lfs"),
"+refs/heads/main:refs/heads/main",
],
);
let d = clones.join("lfs-down");
let _ = fs::remove_dir_all(&d);
let mut c = b.env.cmd("git");
c.env("GIT_LFS_SKIP_SMUDGE", "1")
.args(["clone", "-q", &b.env.http_url("lfs")])
.arg(&d);
run(c);
b.arm("lfs_download", "http", "lfs", cfg.n(3), |b, _| {
let _ = fs::remove_dir_all(d.join(".git/lfs/objects"));
git(&b.env, Some(&d), &["lfs", "fetch", "--all", "origin"]);
bytes
});
}
// ── the console: the repository page's API calls as a guest, and the prerendered page ──
if cfg.wants("console") || cfg.wants("prerender") {
if let Some(ui) = &cfg.ui {
console(&mut b, ui, &root, grpc_port, ui_port, &served);
}
}
b.memory("rss_held", held_repo, repos.len());
let hwm = b.srv.status_kib("VmHWM:") / 1024.0;
println!("== engine {engine} done; server rss {:.1} MiB, last-arm hwm {hwm:.1} MiB, cpu total {:.1} s", b.srv.status_kib("VmRSS:") / 1024.0, b.srv.cpu_ms() / 1e3);
std::mem::take(&mut b.results)
}
fn console(
b: &mut Bench,
ui: &Path,
_root: &Path,
grpc_port: u16,
ui_port: u16,
served: &[(&str, usize)],
) {
let base = b.cfg.work.join(&b.engine).join("ui");
let bundle = base.join("bundle");
fs::create_dir_all(&bundle).unwrap();
// A minimal console document: the prerender is spliced into the console's own index.html.
fs::write(bundle.join("index.html"), "<!doctype html><html><head><meta charset=\"utf-8\"><title>Gunnar</title></head><body><div id=\"app\"></div></body></html>\n").unwrap();
let ident = base.join("ui-id");
run({
let mut c = Command::new("ssh-keygen");
c.args(["-q", "-t", "ed25519", "-N", ""])
.arg("-f")
.arg(&ident);
c
});
let envs: Vec<(String, String)> = vec![
("GUNNAR_UI_CONTROL".into(), format!("127.0.0.1:{grpc_port}")),
("GUNNAR_UI_IDENTITY".into(), ident.display().to_string()),
("GUNNAR_UI_DATA".into(), base.display().to_string()),
("GUNNAR_UI_STATE".into(), base.join("state.redb").display().to_string()),
("GUNNAR_UI_BIND".into(), format!("127.0.0.1:{ui_port}")),
("GUNNAR_UI_DIR".into(), bundle.display().to_string()),
("GUNNAR_UI_WEBAUTHN_RP_ID".into(), "localhost".into()),
("GUNNAR_UI_WEBAUTHN_ORIGIN".into(), format!("http://localhost:{ui_port}")),
("GUNNAR_UI_GUEST_IDENTITY".into(), base.join("guest").display().to_string()),
("GUNNAR_UI_PUBLIC_SITE".into(), "on".into()),
// The bench measures the console, not its throttle's refusals: every tier wide open.
("GUNNAR_UI_THROTTLE".into(), "guest=10000000/10000000/4096,credentialed=10000000/10000000/4096,global=10000000/10000000/8192,render=10000000/10000000,render_all=10000000/10000000".into()),
("RUST_LOG".into(), "warn".into()),
];
let mut c = Command::new(ui);
c.envs(envs.iter().cloned())
.env("HOME", &b.env.home)
.args(["enroll", "--admin-key"])
.arg(b.env.home.join(".ssh/id_ed25519"))
.args(["--name", "gunnar-ui"]);
run(c);
let extra: Vec<(String, String)> = std::env::var("GUNPERF_UI_ENV")
.unwrap_or_default()
.split_whitespace()
.filter_map(|kv| {
kv.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect();
let log = fs::File::create(base.join("ui.log")).unwrap();
let child = Command::new(ui)
.envs(envs.iter().cloned())
.envs(extra)
.env("HOME", &b.env.home)
.stdout(log.try_clone().unwrap())
.stderr(log)
.spawn()
.expect("gunnar-ui");
let pid = child.id();
let mut uip = Proc {
child,
pid,
stopped: false,
};
wait_port(ui_port, "gunnar-ui", &mut uip);
std::thread::sleep(Duration::from_millis(500));
println!(
" console pid {pid} on {ui_port}, idle rss {:.1} MiB",
uip.status_kib("VmRSS:") / 1024.0
);
let port = b.env.http_port;
b.env.http_port = ui_port; // http_arm talks to the console
for (repo, _) in served {
let n = b.cfg.n(200);
let paths = [
("console_repo", format!("/api/repos/bench/{repo}")),
("console_refs", format!("/api/repos/bench/{repo}/refs")),
("console_readme", format!("/api/repos/bench/{repo}/readme")),
(
"console_readme_html",
format!("/api/repos/bench/{repo}/readme?as=html"),
),
(
"console_browse",
format!("/api/repos/bench/{repo}/browse?rev=main&path="),
),
(
"console_log",
format!("/api/repos/bench/{repo}/log?limit=50"),
),
("prerender", format!("/bench/{repo}")),
];
for (arm, p) in paths {
if !b.cfg.wants(arm) && !b.cfg.wants("console") {
continue;
}
// The console's own CPU, not the server's, is what these arms are about: swap.
let ui_cpu0 = uip.cpu_ms();
let srv_cpu0 = b.srv.cpu_ms();
// Latency at c=1 only: the console signs every gRPC call with a single-use
// credential and gunnar remembers 65 536 of them for five minutes, so a burst here
// would fill that guard and refuse every later arm. The burst is its own arm, last.
b.http_arm_for(
arm,
repo,
n,
&p,
"GET",
&[("Accept", "application/json, text/html".into())],
vec![],
1,
Duration::from_secs(1),
);
if let Some(r) = b.results.last_mut() {
r.extra.insert(
"ui_cpu_ms_total".into(),
format!("{:.1}", uip.cpu_ms() - ui_cpu0),
);
r.extra.insert(
"srv_cpu_ms_total".into(),
format!("{:.1}", b.srv.cpu_ms() - srv_cpu0),
);
r.extra.insert(
"ui_rss_mib".into(),
format!("{:.1}", uip.status_kib("VmRSS:") / 1024.0),
);
}
}
}
// The burst, last: c=8 on the repository record for ten seconds, then one c=1 probe of
// what a visitor sees afterwards (the replay guard of gunnar's control plane).
if b.cfg.wants("console_burst") || b.cfg.wants("console") {
if let Some((repo, _)) = served.last() {
let p = format!("/api/repos/bench/{repo}");
let hdr = [("Accept", "application/json, text/html".into())];
b.http_arm_for(
"console_burst",
repo,
20,
&p,
"GET",
&hdr,
vec![],
8,
Duration::from_secs(10),
);
b.http_arm_for(
"console_after",
repo,
20,
&p,
"GET",
&hdr,
vec![],
1,
Duration::from_secs(1),
);
}
}
b.env.http_port = port;
}