crates/gunnar-loadbench/src/stats.rs
//! **Latency and throughput, from pre-allocated samples.**
//!
//! One [`Sample`] slot per planned request exists before a single packet moves —
//! `gatling_for_each` writes each index exactly once and returns the filled
//! vector, so there is no lock, no channel and no growth during the run. The
//! law's "preallocate and reuse" is the whole reason the sample is a 24-byte
//! `Copy` struct and not a `String` per request.
//!
//! Percentiles come from `gatling_sort::gatling_sort_unstable_by` — the
//! constellation's parallel sort, built on the same fork-join pool — so a
//! million-sample run does not spend its tail single-threaded.
use gatling::gatling_sort::gatling_sort_unstable_by;
use crate::outcome::Outcome;
/// One request. `Copy`, 24 bytes, no allocation.
#[derive(Debug, Clone, Copy)]
pub struct Sample {
/// Wall-clock nanos from the request leaving to the body being read.
pub latency_ns: u64,
/// How late this request was against its scheduled send time. A load
/// generator that silently slips its own schedule reports a beautiful p99
/// about a rate it never achieved; this is the number that catches it
/// (coordinated omission, measured rather than assumed away).
pub late_ns: u64,
/// Bytes of body read.
pub bytes: u32,
/// Which bucket it landed in.
pub outcome: Outcome,
}
impl Sample {
pub fn refused(outcome: Outcome, latency_ns: u64, late_ns: u64) -> Sample {
Sample {
latency_ns,
late_ns,
bytes: 0,
outcome,
}
}
}
/// What a probe's samples add up to.
#[derive(Debug, Clone)]
pub struct Stats {
pub n: usize,
/// Counts per reason, in [`Outcome::ALL`] order, zeros included.
pub by_reason: Vec<(&'static str, usize)>,
pub p50_ms: f64,
pub p90_ms: f64,
pub p99_ms: f64,
pub max_ms: f64,
pub mean_ms: f64,
/// Requests per second actually achieved over the wall-clock span.
pub throughput_rps: f64,
/// p99 of the schedule slip. If this is large the achieved rate is the
/// number to believe, not the requested one.
pub p99_late_ms: f64,
pub bytes: u64,
pub wall_s: f64,
}
impl Stats {
#[allow(dead_code)] // used by the tests and by anything reading a Stats directly
pub fn ok_count(&self) -> usize {
self.by_reason
.iter()
.find(|(k, _)| *k == "ok")
.map(|(_, v)| *v)
.unwrap_or(0)
}
/// Reasons that actually happened, for a one-line report.
pub fn nonzero(&self) -> Vec<(&'static str, usize)> {
self.by_reason
.iter()
.copied()
.filter(|(_, v)| *v > 0)
.collect()
}
}
/// Nearest-rank percentile over an already-sorted slice. `p` is 0..=100.
fn pct(sorted: &[u64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let rank = ((p / 100.0) * sorted.len() as f64).ceil() as usize;
let i = rank.saturating_sub(1).min(sorted.len() - 1);
sorted[i] as f64 / 1e6
}
/// Fold samples into a verdict-ready summary. `wall_s` is the observed span of
/// the whole probe, so throughput is what was ACHIEVED, never what was asked
/// for.
pub fn summarise(samples: &[Sample], wall_s: f64) -> Stats {
let n = samples.len();
let mut by_reason: Vec<(&'static str, usize)> =
Outcome::ALL.iter().map(|o| (o.key(), 0usize)).collect();
let mut bytes: u64 = 0;
// Pre-allocated once; no push-growth in the hot fold.
let mut lat: Vec<u64> = Vec::with_capacity(n);
let mut late: Vec<u64> = Vec::with_capacity(n);
let mut sum_ns: u128 = 0;
for s in samples {
let idx = Outcome::ALL
.iter()
.position(|o| *o == s.outcome)
.unwrap_or(0);
by_reason[idx].1 += 1;
bytes += s.bytes as u64;
lat.push(s.latency_ns);
late.push(s.late_ns);
sum_ns += s.latency_ns as u128;
}
gatling_sort_unstable_by(&mut lat, |a, b| a.cmp(b));
gatling_sort_unstable_by(&mut late, |a, b| a.cmp(b));
Stats {
n,
by_reason,
p50_ms: pct(&lat, 50.0),
p90_ms: pct(&lat, 90.0),
p99_ms: pct(&lat, 99.0),
max_ms: lat.last().copied().unwrap_or(0) as f64 / 1e6,
mean_ms: if n == 0 {
0.0
} else {
(sum_ns / n as u128) as f64 / 1e6
},
throughput_rps: if wall_s > 0.0 { n as f64 / wall_s } else { 0.0 },
p99_late_ms: pct(&late, 99.0),
bytes,
wall_s,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(ms: u64, o: Outcome) -> Sample {
Sample {
latency_ns: ms * 1_000_000,
late_ns: 0,
bytes: 10,
outcome: o,
}
}
#[test]
fn percentiles_are_nearest_rank_and_max_is_the_last() {
let v: Vec<Sample> = (1..=100).map(|i| s(i, Outcome::Ok)).collect();
let st = summarise(&v, 1.0);
assert_eq!(st.p50_ms, 50.0);
assert_eq!(st.p90_ms, 90.0);
assert_eq!(st.p99_ms, 99.0);
assert_eq!(st.max_ms, 100.0);
}
#[test]
fn a_single_sample_is_every_percentile() {
let st = summarise(&[s(7, Outcome::Ok)], 1.0);
assert_eq!((st.p50_ms, st.p99_ms, st.max_ms), (7.0, 7.0, 7.0));
}
#[test]
fn refusals_are_counted_apart_and_zeros_are_still_reported() {
let v = vec![
s(1, Outcome::Ok),
s(2, Outcome::Timeout),
s(3, Outcome::Timeout),
s(4, Outcome::Maintenance),
];
let st = summarise(&v, 2.0);
assert_eq!(st.ok_count(), 1);
let nz = st.nonzero();
assert!(nz.contains(&("timeout", 2)), "{nz:?}");
assert!(nz.contains(&("maintenance", 1)), "{nz:?}");
// every bucket present, including the ones that did not happen
assert_eq!(st.by_reason.len(), Outcome::ALL.len());
assert!(st.by_reason.iter().any(|(k, v)| *k == "tls" && *v == 0));
}
#[test]
fn throughput_is_achieved_not_requested() {
let v: Vec<Sample> = (0..10).map(|_| s(1, Outcome::Ok)).collect();
// asked for 100/s, the run took 5 s: the answer is 2/s.
assert_eq!(summarise(&v, 5.0).throughput_rps, 2.0);
}
#[test]
fn an_empty_probe_does_not_divide_by_zero() {
let st = summarise(&[], 0.0);
assert_eq!(st.n, 0);
assert_eq!(st.throughput_rps, 0.0);
assert_eq!(st.p99_ms, 0.0);
}
}