use std::collections::HashMap;
use std::panic::{RefUnwindSafe, UnwindSafe};
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,
}
#[ohno::error]
#[derive(Clone)]
#[no_constructors]
#[from(MissingCaseError, MissingMetricError)]
pub struct AggregateError;
impl UnwindSafe for AggregateError {}
impl RefUnwindSafe for AggregateError {}
#[ohno::error]
#[display(
"benchmark case '{id}' is missing from run {}; every best-of run must \
measure the same set of cases",
run_index.saturating_add(1)
)]
struct MissingCaseError {
id: BenchmarkId,
run_index: usize,
}
impl UnwindSafe for MissingCaseError {}
impl RefUnwindSafe for MissingCaseError {}
#[ohno::error]
#[display(
"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)
)]
struct MissingMetricError {
id: BenchmarkId,
kind: MetricKind,
run_index: usize,
}
impl UnwindSafe for MissingMetricError {}
impl RefUnwindSafe for MissingMetricError {}
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(MissingCaseError::new(reference_result.id.clone(), run_index).into());
}
}
for result in run {
if !reference_lookup.contains_key(&result.id) {
return Err(MissingCaseError::new(result.id.clone(), 0_usize).into());
}
}
}
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(MissingMetricError::new(
id.clone(),
reference_metric.kind,
run_index,
)
.into());
}
}
for metric in &result.metrics {
if find_metric(reference_result, metric.kind).is_none() {
return Err(MissingMetricError::new(id.clone(), metric.kind, 0_usize).into());
}
}
}
}
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 std::error;
use std::fmt::Debug;
use std::panic::{RefUnwindSafe, UnwindSafe};
use nonempty::nonempty;
use ohno::ErrorExt;
use static_assertions::assert_impl_all;
use super::*;
use crate::Metric;
assert_impl_all!(
AggregateError: Clone,
Send,
Sync,
Debug,
error::Error,
UnwindSafe,
RefUnwindSafe
);
assert_impl_all!(
MissingCaseError: Send,
Sync,
Debug,
error::Error,
UnwindSafe,
RefUnwindSafe
);
assert_impl_all!(
MissingMetricError: Send,
Sync,
Debug,
error::Error,
UnwindSafe,
RefUnwindSafe
);
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();
let missing = error.find_source::<MissingCaseError>().unwrap();
assert_eq!(missing.id.qualified(), "b");
assert_eq!(missing.run_index, 1);
}
#[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();
let missing = error.find_source::<MissingCaseError>().unwrap();
assert_eq!(missing.id.qualified(), "b");
assert_eq!(missing.run_index, 0);
}
#[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();
let missing = error.find_source::<MissingMetricError>().unwrap();
assert_eq!(missing.id.qualified(), "case");
assert_eq!(missing.kind, MetricKind::ProcessorTime);
assert_eq!(missing.run_index, 1);
}
#[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();
let missing = error.find_source::<MissingMetricError>().unwrap();
assert_eq!(missing.kind, MetricKind::ProcessorTime);
assert_eq!(missing.run_index, 0);
}
#[test]
fn errors_carry_the_missing_case_metric_and_run() {
let missing_case = MissingCaseError::new(id("some/case"), 2_usize);
assert_eq!(missing_case.id.qualified(), "some/case");
assert_eq!(missing_case.run_index, 2);
let missing_metric =
MissingMetricError::new(id("some/case"), MetricKind::WallTime, 0_usize);
assert_eq!(missing_metric.id.qualified(), "some/case");
assert_eq!(missing_metric.kind, MetricKind::WallTime);
assert_eq!(missing_metric.run_index, 0);
}
#[test]
fn aggregate_error_displays_as_the_underlying_inconsistency() {
let missing_case = MissingCaseError::new(id("some/case"), 1_usize);
let expected = missing_case.to_string();
let error = AggregateError::from(missing_case);
assert!(error.to_string().starts_with(&expected));
}
}