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

nordisk/gunnar

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);
    }
}