use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use crate::{BenchmarkId, BenchmarkResult, MetricKind, MetricList};
#[derive(Clone, Debug, PartialEq)]
pub struct Combined {
pub results: Vec<BenchmarkResult>,
pub selections: Vec<Selection>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Selection {
pub id: BenchmarkId,
pub kind: MetricKind,
pub samples: Vec<f64>,
pub chosen_run: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AggregateError {
MissingCase {
id: BenchmarkId,
run_index: usize,
},
MissingMetric {
id: BenchmarkId,
kind: MetricKind,
run_index: usize,
},
}
impl fmt::Display for AggregateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingCase { id, run_index } => write!(
f,
"benchmark case '{id}' is missing from run {}; every best-of run must \
measure the same set of cases",
run_index.saturating_add(1)
),
Self::MissingMetric {
id,
kind,
run_index,
} => write!(
f,
"metric '{}' for benchmark case '{id}' is missing from run {}; every \
best-of run must report the same metrics per case",
kind.as_str(),
run_index.saturating_add(1)
),
}
}
}
impl Error for AggregateError {}
pub fn min_per_metric(runs: &[Vec<BenchmarkResult>]) -> Result<Combined, AggregateError> {
let Some((reference, rest)) = runs.split_first() else {
return Ok(Combined {
results: Vec::new(),
selections: Vec::new(),
});
};
let indexed: Vec<HashMap<&BenchmarkId, &BenchmarkResult>> = runs
.iter()
.map(|results| results.iter().map(|result| (&result.id, result)).collect())
.collect();
check_case_consistency(reference, rest, &indexed)?;
check_metric_consistency(reference, &indexed)?;
let mut results = Vec::with_capacity(reference.len());
let mut selections = Vec::new();
for reference_result in reference {
let id = &reference_result.id;
let mut metrics = MetricList::new();
for reference_metric in &reference_result.metrics {
let kind = reference_metric.kind;
let mut samples = Vec::with_capacity(indexed.len());
let mut winner: Option<&crate::Metric> = None;
let mut chosen_run = 0_usize;
for (run_index, run) in indexed.iter().enumerate() {
let metric = run
.get(id)
.and_then(|result| find_metric(result, kind))
.expect("consistency check guarantees this metric is in every run");
let is_better = match winner {
Some(best) => metric.value < best.value,
None => true,
};
if is_better {
winner = Some(metric);
chosen_run = run_index;
}
samples.push(metric.value);
}
let winner = winner
.expect("a reduced metric is measured in at least one run")
.clone();
metrics.push(winner);
selections.push(Selection {
id: id.clone(),
kind,
samples,
chosen_run,
});
}
results.push(BenchmarkResult {
id: id.clone(),
metrics,
});
}
Ok(Combined {
results,
selections,
})
}
fn check_case_consistency(
reference: &[BenchmarkResult],
rest: &[Vec<BenchmarkResult>],
indexed: &[HashMap<&BenchmarkId, &BenchmarkResult>],
) -> Result<(), AggregateError> {
let Some((reference_lookup, rest_lookups)) = indexed.split_first() else {
return Ok(());
};
for (offset, (run, lookup)) in rest.iter().zip(rest_lookups).enumerate() {
let run_index = offset.saturating_add(1);
for reference_result in reference {
if !lookup.contains_key(&reference_result.id) {
return Err(AggregateError::MissingCase {
id: reference_result.id.clone(),
run_index,
});
}
}
for result in run {
if !reference_lookup.contains_key(&result.id) {
return Err(AggregateError::MissingCase {
id: result.id.clone(),
run_index: 0,
});
}
}
}
Ok(())
}
fn check_metric_consistency(
reference: &[BenchmarkResult],
indexed: &[HashMap<&BenchmarkId, &BenchmarkResult>],
) -> Result<(), AggregateError> {
for (run_index, lookup) in indexed.iter().enumerate() {
for reference_result in reference {
let id = &reference_result.id;
let result = lookup
.get(id)
.expect("case consistency guarantees every run holds this case");
for reference_metric in &reference_result.metrics {
if find_metric(result, reference_metric.kind).is_none() {
return Err(AggregateError::MissingMetric {
id: id.clone(),
kind: reference_metric.kind,
run_index,
});
}
}
for metric in &result.metrics {
if find_metric(reference_result, metric.kind).is_none() {
return Err(AggregateError::MissingMetric {
id: id.clone(),
kind: metric.kind,
run_index: 0,
});
}
}
}
}
Ok(())
}
fn find_metric(result: &BenchmarkResult, kind: MetricKind) -> Option<&crate::Metric> {
result.metrics.iter().find(|metric| metric.kind == kind)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
#![allow(clippy::indexing_slicing, reason = "panic is fine in tests")]
#![allow(
clippy::float_cmp,
reason = "aggregated values are exact copies of the inputs, not computed"
)]
use nonempty::nonempty;
use super::*;
use crate::Metric;
fn id(name: &str) -> BenchmarkId {
BenchmarkId::new(nonempty![name.to_owned()])
}
fn result(name: &str, metrics: Vec<Metric>) -> BenchmarkResult {
BenchmarkResult::new(id(name), metrics)
}
fn wall(value: f64) -> Metric {
Metric::new(MetricKind::WallTime, value)
}
#[test]
fn empty_input_yields_empty_output() {
let combined = min_per_metric(&[]).unwrap();
assert!(combined.results.is_empty());
assert!(combined.selections.is_empty());
}
#[test]
fn single_run_is_returned_unchanged() {
let metric = wall(7.4).with_dispersion(Some(0.5), Some(7.0), Some(7.9));
let runs = vec![vec![result("case", vec![metric.clone()])]];
let combined = min_per_metric(&runs).unwrap();
assert_eq!(combined.results, runs[0]);
assert_eq!(combined.results[0].metrics[0], metric);
assert_eq!(combined.selections.len(), 1);
assert_eq!(combined.selections[0].chosen_run, 0);
assert_eq!(combined.selections[0].samples, vec![7.4]);
}
#[test]
fn minimum_value_is_selected_with_its_own_dispersion() {
let low = wall(5.0).with_dispersion(Some(0.1), Some(4.9), Some(5.1));
let runs = vec![
vec![result("case", vec![wall(9.0)])],
vec![result("case", vec![low.clone()])],
vec![result("case", vec![wall(7.0)])],
];
let combined = min_per_metric(&runs).unwrap();
assert_eq!(combined.results.len(), 1);
assert_eq!(combined.results[0].metrics[0], low);
let selection = &combined.selections[0];
assert_eq!(selection.samples, vec![9.0, 5.0, 7.0]);
assert_eq!(selection.chosen_run, 1);
}
#[test]
fn each_metric_is_minimized_independently() {
let runs = vec![
vec![result(
"case",
vec![wall(5.0), Metric::new(MetricKind::ProcessorTime, 20.0)],
)],
vec![result(
"case",
vec![wall(8.0), Metric::new(MetricKind::ProcessorTime, 11.0)],
)],
];
let combined = min_per_metric(&runs).unwrap();
let metrics = &combined.results[0].metrics;
assert_eq!(metrics[0].value, 5.0);
assert_eq!(metrics[1].value, 11.0);
assert_eq!(combined.selections[0].chosen_run, 0);
assert_eq!(combined.selections[1].chosen_run, 1);
}
#[test]
fn ties_resolve_to_the_earliest_run() {
let runs = vec![
vec![result("case", vec![wall(5.0)])],
vec![result("case", vec![wall(5.0)])],
];
let combined = min_per_metric(&runs).unwrap();
assert_eq!(combined.selections[0].chosen_run, 0);
}
#[test]
fn case_and_metric_order_follow_the_first_run() {
let runs = vec![
vec![
result("beta", vec![wall(2.0)]),
result("alpha", vec![wall(1.0)]),
],
vec![
result("alpha", vec![wall(1.0)]),
result("beta", vec![wall(2.0)]),
],
];
let combined = min_per_metric(&runs).unwrap();
let names: Vec<String> = combined
.results
.iter()
.map(|result| result.id.qualified())
.collect();
assert_eq!(names, vec!["beta".to_owned(), "alpha".to_owned()]);
}
#[test]
fn a_case_missing_from_a_later_run_is_an_error() {
let runs = vec![
vec![result("a", vec![wall(1.0)]), result("b", vec![wall(1.0)])],
vec![result("a", vec![wall(1.0)])],
];
let error = min_per_metric(&runs).unwrap_err();
match error {
AggregateError::MissingCase { id, run_index } => {
assert_eq!(id.qualified(), "b");
assert_eq!(run_index, 1);
}
other => panic!("expected a missing-case error, got {other:?}"),
}
}
#[test]
fn a_case_only_in_a_later_run_is_an_error() {
let runs = vec![
vec![result("a", vec![wall(1.0)])],
vec![result("a", vec![wall(1.0)]), result("b", vec![wall(1.0)])],
];
let error = min_per_metric(&runs).unwrap_err();
match error {
AggregateError::MissingCase { id, run_index } => {
assert_eq!(id.qualified(), "b");
assert_eq!(run_index, 0);
}
other => panic!("expected a missing-case error, got {other:?}"),
}
}
#[test]
fn a_metric_missing_from_a_later_run_is_an_error() {
let runs = vec![
vec![result(
"case",
vec![wall(1.0), Metric::new(MetricKind::ProcessorTime, 2.0)],
)],
vec![result("case", vec![wall(1.0)])],
];
let error = min_per_metric(&runs).unwrap_err();
match error {
AggregateError::MissingMetric {
id,
kind,
run_index,
} => {
assert_eq!(id.qualified(), "case");
assert_eq!(kind, MetricKind::ProcessorTime);
assert_eq!(run_index, 1);
}
other => panic!("expected a missing-metric error, got {other:?}"),
}
}
#[test]
fn an_extra_metric_in_a_later_run_is_an_error() {
let runs = vec![
vec![result("case", vec![wall(1.0)])],
vec![result(
"case",
vec![wall(1.0), Metric::new(MetricKind::ProcessorTime, 2.0)],
)],
];
let error = min_per_metric(&runs).unwrap_err();
match error {
AggregateError::MissingMetric {
kind, run_index, ..
} => {
assert_eq!(kind, MetricKind::ProcessorTime);
assert_eq!(run_index, 0);
}
other => panic!("expected a missing-metric error, got {other:?}"),
}
}
#[test]
fn error_messages_name_the_case_and_run() {
let missing_case = AggregateError::MissingCase {
id: id("some/case"),
run_index: 2,
};
let text = missing_case.to_string();
assert!(text.contains("some/case"), "{text}");
assert!(text.contains("run 3"), "{text}");
let missing_metric = AggregateError::MissingMetric {
id: id("some/case"),
kind: MetricKind::WallTime,
run_index: 0,
};
let text = missing_metric.to_string();
assert!(text.contains("wall_time"), "{text}");
assert!(text.contains("run 1"), "{text}");
}
}