use crate::{Error, Result, Stream};
use std::{collections::BTreeMap, time::Instant};
#[must_use]
pub fn percentile(sorted: &[f64], percent: usize) -> f64 {
assert!(!sorted.is_empty() && (1..=100).contains(&percent));
sorted[(sorted.len() * percent).div_ceil(100) - 1]
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct Distribution {
pub median_ms: f64,
pub p95_ms: f64,
}
impl Distribution {
pub fn from_samples(mut samples: Vec<f64>) -> Result<Self> {
if samples.is_empty()
|| samples
.iter()
.any(|sample| !sample.is_finite() || *sample < 0.0)
{
return Err(Error::Message(
"benchmark samples must be finite, nonnegative, and nonempty".into(),
));
}
samples.sort_by(f64::total_cmp);
Ok(Self {
median_ms: percentile(&samples, 50),
p95_ms: percentile(&samples, 95),
})
}
}
#[derive(Default)]
pub struct StageTimings {
samples: BTreeMap<String, Vec<f64>>,
}
impl StageTimings {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn measure<T>(
&mut self,
stream: &mut Stream,
name: impl Into<String>,
stage: impl FnOnce(&mut Stream) -> Result<T>,
) -> Result<T> {
stream.synchronize()?;
let start = Instant::now();
let value = stage(stream)?;
stream.synchronize()?;
self.samples
.entry(name.into())
.or_default()
.push(start.elapsed().as_secs_f64() * 1_000.0);
Ok(value)
}
pub fn push(&mut self, name: impl Into<String>, milliseconds: f64) -> Result<()> {
if !milliseconds.is_finite() || milliseconds < 0.0 {
return Err(Error::Message("invalid benchmark duration".into()));
}
self.samples
.entry(name.into())
.or_default()
.push(milliseconds);
Ok(())
}
pub fn distributions(&self) -> Result<BTreeMap<String, Distribution>> {
self.samples
.iter()
.map(|(name, samples)| Ok((name.clone(), Distribution::from_samples(samples.clone())?)))
.collect()
}
#[must_use]
pub fn totals_ms(&self) -> BTreeMap<String, f64> {
self.samples
.iter()
.map(|(name, samples)| (name.clone(), samples.iter().sum()))
.collect()
}
pub fn take_totals_ms(&mut self) -> BTreeMap<String, f64> {
std::mem::take(&mut self.samples)
.into_iter()
.map(|(name, samples)| (name, samples.into_iter().sum()))
.collect()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_rank_includes_the_correct_order_statistic() {
let values: Vec<_> = (1..=100).map(f64::from).collect();
assert_eq!(percentile(&values, 50), 50.0);
assert_eq!(percentile(&values, 95), 95.0);
assert_eq!(percentile(&values[..50], 50), 25.0);
assert_eq!(percentile(&values[..50], 95), 48.0);
assert_eq!(percentile(&[7.0], 95), 7.0);
}
#[test]
fn distributions_reject_bad_samples() {
assert!(Distribution::from_samples(vec![]).is_err());
assert!(Distribution::from_samples(vec![f64::NAN]).is_err());
let value = Distribution::from_samples((1..=100).map(f64::from).collect()).unwrap();
assert_eq!(value.median_ms, 50.0);
assert_eq!(value.p95_ms, 95.0);
}
#[test]
fn stage_totals_can_be_drained() {
let mut timings = StageTimings::new();
timings.push("load", 1.25).unwrap();
timings.push("load", 2.75).unwrap();
timings.push("store", 3.0).unwrap();
assert_eq!(timings.totals_ms()["load"], 4.0);
assert_eq!(timings.take_totals_ms()["store"], 3.0);
assert!(timings.is_empty());
}
}