Use parameterized benchmarks as references

This commit is contained in:
David Peter committed 2026-10-05 14:59:18 +02:00
1 parent 5d501b62f9
commit f7429e2fb2
13 files changed
+856 -71

No files matched your search

+77 -5
View File
@@ -29,7 +29,7 @@ pub fn fastest_of(results: &[BenchmarkResult]) -> &BenchmarkResult {
.expect("at least one benchmark result")
}
fn compute_relative_speeds<'a>(
pub fn compute_relative_speeds<'a>(
results: &'a [BenchmarkResult],
reference: &'a BenchmarkResult,
sort_order: SortOrder,
@@ -37,10 +37,13 @@ fn compute_relative_speeds<'a>(
let mut results: Vec<_> = results
.iter()
.map(|result| {
let is_reference = result == reference;
// Separate benchmarks can have identical names and measurements.
let is_reference = std::ptr::eq(result, reference);
let relative_ordering = compare_mean_time(result, reference);
if result.mean_wall_clock_time() == Time::zero() {
if result.mean_wall_clock_time() == Time::zero()
|| reference.mean_wall_clock_time() == Time::zero()
{
return BenchmarkResultWithRelativeSpeed {
result,
relative_speed: if is_reference { 1.0 } else { f64::INFINITY },
@@ -64,11 +67,11 @@ fn compute_relative_speeds<'a>(
result.measurements.stddev(),
reference.measurements.stddev(),
) {
(Some(result_stddev), Some(fastest_stddev)) => Some(
(Some(result_stddev), Some(reference_stddev)) => Some(
ratio
* ((result_stddev / result.mean_wall_clock_time())
.powi(uom::typenum::P2::new())
+ (fastest_stddev / reference.mean_wall_clock_time())
+ (reference_stddev / reference.mean_wall_clock_time())
.powi(uom::typenum::P2::new()))
.sqrt(),
),
@@ -192,3 +195,72 @@ fn test_compute_relative_speed_for_zero_times() {
assert!(annotated_results.is_none());
}
#[test]
fn reference_identity_distinguishes_equal_results() {
let results = vec![create_result("same", 1.0), create_result("same", 1.0)];
let entries = compute_relative_speeds(&results, &results[0], SortOrder::Command);
assert!(entries[0].is_reference);
assert!(!entries[1].is_reference);
}
#[test]
fn reference_ratios_indicate_faster_and_slower_runs() {
use approx::assert_relative_eq;
let results = vec![
create_result("faster", 1.0),
create_result("reference", 2.0),
create_result("slower", 3.0),
];
let entries = compute_relative_speeds(&results, &results[1], SortOrder::Command);
assert_relative_eq!(entries[0].relative_speed, 2.0);
assert_relative_eq!(entries[1].relative_speed, 1.0);
assert_relative_eq!(entries[2].relative_speed, 1.5);
assert!(!entries[0].is_reference);
assert!(entries[1].is_reference);
assert!(!entries[2].is_reference);
}
#[test]
fn reference_ratios_handle_zero_times() {
let mut results = vec![create_result("reference", 2.0), create_result("zero", 0.0)];
for result in &mut results {
let measurement = result.measurements.measurements[0];
result.measurements.measurements.push(measurement);
}
let entries = compute_relative_speeds(&results, &results[0], SortOrder::Command);
assert_eq!(entries[1].relative_speed, f64::INFINITY);
let entries = compute_relative_speeds(&results, &results[1], SortOrder::Command);
assert_eq!(entries[0].relative_speed, f64::INFINITY);
assert_eq!(entries[0].relative_speed_stddev, None);
assert_eq!(entries[1].relative_speed, 1.0);
}
#[test]
fn reference_ratio_propagates_uncertainty() {
use crate::benchmark::measurement::Measurement;
use crate::quantity::second;
use approx::assert_relative_eq;
let mut results = vec![
create_result("faster", 1.0),
create_result("reference", 3.0),
];
for (result, times) in results.iter_mut().zip([[1.0, 2.0, 3.0], [3.0, 4.0, 5.0]]) {
result.measurements.measurements = times
.iter()
.copied()
.map(|seconds| Measurement {
time_wall_clock: Time::new::<second>(seconds),
..Default::default()
})
.collect();
}
let entries = compute_relative_speeds(&results, &results[1], SortOrder::Command);
assert_relative_eq!(entries[0].relative_speed, 2.0);
assert_relative_eq!(entries[0].relative_speed_stddev.unwrap(), 1.118033988749895);
}
+27 -7
View File
@@ -44,6 +44,7 @@ impl<'a> Scheduler<'a> {
.options
.reference_command
.as_ref()
.filter(|_| !self.options.parameterized_mode)
.map(|cmd| Command::new(self.options.reference_name.as_deref(), cmd));
executor.calibrate()?;
@@ -54,7 +55,8 @@ impl<'a> Scheduler<'a> {
// We export results after each individual benchmark, because
// we would risk losing them if a later benchmark fails.
self.export_manager.write_results(&self.results, true)?;
self.export_manager
.write_results(&self.results, true, self.reference_index())?;
}
Ok(())
@@ -70,10 +72,8 @@ impl<'a> Scheduler<'a> {
}
let reference = self
.options
.reference_command
.as_ref()
.map(|_| &self.results[0])
.reference_index()
.map(|index| &self.results[index])
.unwrap_or_else(|| relative_speed::fastest_of(&self.results));
if let Some(annotated_results) = relative_speed::compute_with_check_from_reference(
@@ -130,9 +130,20 @@ impl<'a> Scheduler<'a> {
console_writeln!(stdout, "{}", "Relative speed comparison".bold())?;
for item in annotated_results {
let relationship = if self.reference_index().is_none() {
""
} else if item.is_reference {
" (reference)"
} else {
match item.relative_ordering {
Ordering::Less => " (faster)",
Ordering::Equal => " (same speed)",
Ordering::Greater => " (slower)",
}
};
console_writeln!(
stdout,
" {}{} {}",
" {}{} {}{}",
format!("{:10.2}", item.relative_speed).bold().green(),
if item.is_reference {
" ".into()
@@ -142,6 +153,7 @@ impl<'a> Scheduler<'a> {
" ".into()
},
item.result.command_with_unused_parameters,
relationship,
)?;
}
}
@@ -162,8 +174,16 @@ impl<'a> Scheduler<'a> {
Ok(())
}
fn reference_index(&self) -> Option<usize> {
self.options
.reference_command
.as_ref()
.map(|_| self.options.parameterized_reference_index.unwrap_or(0))
}
pub fn final_export(&self) -> Result<()> {
self.export_manager.write_results(&self.results, false)
self.export_manager
.write_results(&self.results, false, self.reference_index())
}
}
+7 -3
View File
@@ -105,8 +105,11 @@ fn build_command() -> Command {
.action(ArgAction::Set)
.value_name("CMD")
.help(
"The reference command for the relative comparison of results. \
If this is unset, results are compared with the fastest command as reference."
"The reference for the relative comparison of results. Without parameters, \
CMD is run as a separate reference command. With --parameter-scan or \
--parameter-list, CMD must exactly match one unique benchmark name as \
shown in the output. If this is unset, results are compared with the \
fastest command as reference."
)
)
.arg(
@@ -114,7 +117,8 @@ fn build_command() -> Command {
.long("reference-name")
.action(ArgAction::Set)
.value_name("CMD")
.help("Give a meaningful name to the reference command.")
.help("Give a meaningful name to the reference command. This cannot be used \
with parameterized benchmarks; use --command-name instead.")
.requires("reference")
)
.arg(
+2 -1
View File
@@ -18,6 +18,7 @@ impl Exporter for CsvExporter {
results: &[BenchmarkResult],
_unit: Option<TimeUnit>,
_sort_order: SortOrder,
_reference_index: Option<usize>,
) -> Result<Vec<u8>> {
const CSV_UNIT: TimeUnit = TimeUnit::Second;
const CSV_PRECISION: usize = 6;
@@ -172,7 +173,7 @@ fn test_csv() {
let actual = String::from_utf8(
exporter
.serialize(&results, Some(TimeUnit::Second), SortOrder::Command)
.serialize(&results, Some(TimeUnit::Second), SortOrder::Command, None)
.unwrap(),
)
.unwrap();
+1
View File
@@ -22,6 +22,7 @@ impl Exporter for JsonExporter {
results: &[BenchmarkResult],
_unit: Option<TimeUnit>,
_sort_order: SortOrder,
_reference_index: Option<usize>,
) -> Result<Vec<u8>> {
let mut output = to_vec_pretty(&HyperfineSummary { results });
if let Ok(ref mut content) = output {
+39 -6
View File
@@ -1,10 +1,11 @@
use crate::benchmark::relative_speed::BenchmarkResultWithRelativeSpeed;
use crate::benchmark::{benchmark_result::BenchmarkResult, relative_speed};
use crate::options::SortOrder;
use crate::quantity::{FormatQuantity, IsUnit, TimeUnit};
use crate::quantity::{FormatQuantity, IsUnit, Time, TimeUnit, Zero};
use super::Exporter;
use anyhow::Result;
use std::cmp::Ordering;
pub enum Alignment {
Left,
@@ -16,6 +17,8 @@ pub trait MarkupExporter {
&self,
entries: &[BenchmarkResultWithRelativeSpeed],
unit: TimeUnit,
reference_pending: bool,
explicit_reference: bool,
) -> String {
// prepare table header strings
let notation = format!("[{}]", unit.short_name());
@@ -58,14 +61,35 @@ pub trait MarkupExporter {
};
let min_str = measurement.measurements.min().format_value(unit);
let max_str = measurement.measurements.max().format_value(unit);
let rel_str = format!("{:.2}", entry.relative_speed);
let rel_stddev_str = if entry.is_reference {
// The ratio of two zero times is undefined, even if they compare equal.
let relative_unavailable = reference_pending
|| (explicit_reference
&& !entry.is_reference
&& entry.relative_ordering == Ordering::Equal
&& measurement.mean_wall_clock_time() == Time::zero());
let rel_str = if relative_unavailable {
"N/A".to_string()
} else {
format!("{:.2}", entry.relative_speed)
};
let rel_stddev_str = if relative_unavailable || entry.is_reference {
"".into()
} else if let Some(stddev) = entry.relative_speed_stddev {
format!(" ± {stddev:.2}")
} else {
"".into()
};
let rel_direction = if relative_unavailable || !explicit_reference {
""
} else if entry.is_reference {
" (reference)"
} else {
match entry.relative_ordering {
Ordering::Less => " (faster)",
Ordering::Greater => " (slower)",
Ordering::Equal => " (same speed)",
}
};
// prepare table row entries
table.push_str(&self.table_row(&[
@@ -73,7 +97,7 @@ pub trait MarkupExporter {
&format!("{mean_str}{stddev_str}"),
&min_str,
&max_str,
&format!("{rel_str}{rel_stddev_str}"),
&format!("{rel_str}{rel_stddev_str}{rel_direction}"),
]))
}
@@ -114,11 +138,20 @@ impl<T: MarkupExporter> Exporter for T {
results: &[BenchmarkResult],
unit: Option<TimeUnit>,
sort_order: SortOrder,
reference_index: Option<usize>,
) -> Result<Vec<u8>> {
let unit = unit.unwrap_or_else(|| determine_unit_from_results(results));
let entries = relative_speed::compute(results, sort_order);
// Do not report ratios against another benchmark while the selected
// reference is still pending in an intermediate export.
let reference_pending = reference_index.is_some_and(|i| i >= results.len());
let entries = if let Some(reference) = reference_index.and_then(|i| results.get(i)) {
relative_speed::compute_relative_speeds(results, reference, sort_order)
} else {
relative_speed::compute(results, sort_order)
};
let table = self.table_results(&entries, unit);
let table =
self.table_results(&entries, unit, reference_pending, reference_index.is_some());
Ok(table.as_bytes().to_vec())
}
}
+10 -2
View File
@@ -51,6 +51,7 @@ trait Exporter {
results: &[BenchmarkResult],
unit: Option<TimeUnit>,
sort_order: SortOrder,
reference_index: Option<usize>,
) -> Result<Vec<u8>>;
}
@@ -130,11 +131,18 @@ impl ExportManager {
/// results are written to all file targets (to always have them up to date, even
/// if a benchmark fails). In the latter case, we only print to stdout targets (in
/// order not to clutter the output of hyperfine with intermediate results).
pub fn write_results(&self, results: &[BenchmarkResult], intermediate: bool) -> Result<()> {
/// `reference_index` refers to the complete benchmark sequence, so it can
/// point past `results` while that benchmark is still pending.
pub fn write_results(
&self,
results: &[BenchmarkResult],
intermediate: bool,
reference_index: Option<usize>,
) -> Result<()> {
for e in &self.exporters {
let content = || {
e.exporter
.serialize(results, self.time_unit, self.sort_order)
.serialize(results, self.time_unit, self.sort_order, reference_index)
};
match e.target {
+166 -1
View File
@@ -12,9 +12,174 @@ fn get_output<E: Exporter + Default>(
results: &[BenchmarkResult],
unit: Option<TimeUnit>,
sort_order: SortOrder,
) -> String {
get_output_with_reference::<E>(results, unit, sort_order, None)
}
fn get_output_with_reference<E: Exporter + Default>(
results: &[BenchmarkResult],
unit: Option<TimeUnit>,
sort_order: SortOrder,
reference_index: Option<usize>,
) -> String {
let exporter = E::default();
String::from_utf8(exporter.serialize(results, unit, sort_order).unwrap()).unwrap()
String::from_utf8(
exporter
.serialize(results, unit, sort_order, reference_index)
.unwrap(),
)
.unwrap()
}
#[test]
fn test_markup_exports_identify_explicit_reference_and_direction() {
let benchmark = |name: &str, seconds| {
let measurement = Measurement {
time_wall_clock: Time::new::<second>(seconds),
..Measurement::default()
};
BenchmarkResult {
command: name.to_owned(),
command_with_unused_parameters: name.to_owned(),
measurements: Measurements::new(vec![measurement, measurement]),
..BenchmarkResult::default()
}
};
let results = [
benchmark("faster", 1.0),
benchmark("reference", 2.0),
benchmark("equal", 2.0),
benchmark("slower", 3.0),
];
for sort_order in [SortOrder::Command, SortOrder::MeanTime] {
let outputs = [
get_output_with_reference::<MarkdownExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
Some(1),
),
get_output_with_reference::<AsciidocExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
Some(1),
),
get_output_with_reference::<OrgmodeExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
Some(1),
),
];
for output in outputs {
for expected in [
"2.00 ± 0.00 (faster)",
"1.00 (reference)",
"1.00 ± 0.00 (same speed)",
"1.50 ± 0.00 (slower)",
] {
assert!(output.contains(expected), "{}", output);
}
}
}
}
#[test]
fn test_markup_exports_leave_pending_reference_ratios_unavailable() {
let results = [BenchmarkResult {
command: "sleep 1".into(),
command_with_unused_parameters: "sleep 1".into(),
measurements: Measurements::new(vec![Measurement {
time_wall_clock: Time::new::<second>(1.0),
..Measurement::default()
}]),
..BenchmarkResult::default()
}];
for sort_order in [SortOrder::Command, SortOrder::MeanTime] {
let outputs = [
get_output_with_reference::<MarkdownExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
Some(1),
),
get_output_with_reference::<AsciidocExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
Some(1),
),
get_output_with_reference::<OrgmodeExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
Some(1),
),
];
for output in outputs {
assert!(output.contains("N/A"), "{}", output);
assert!(!output.contains("(reference)"), "{}", output);
}
}
}
#[test]
fn test_markup_exports_with_zero_reference() {
let benchmark = |name: &str, seconds| BenchmarkResult {
command: name.to_owned(),
command_with_unused_parameters: name.to_owned(),
measurements: Measurements::new(vec![Measurement {
time_wall_clock: Time::new::<second>(seconds),
..Measurement::default()
}]),
..BenchmarkResult::default()
};
let results = [
benchmark("other zero", 0.0),
benchmark("reference", 0.0),
benchmark("slower", 1.0),
];
for sort_order in [SortOrder::Command, SortOrder::MeanTime] {
for reference in [Some(1), Some(3), None] {
let outputs = [
get_output_with_reference::<MarkdownExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
reference,
),
get_output_with_reference::<AsciidocExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
reference,
),
get_output_with_reference::<OrgmodeExporter>(
&results,
Some(TimeUnit::Second),
sort_order,
reference,
),
];
for output in outputs {
match reference {
Some(1) => {
assert_eq!(output.matches("N/A").count(), 1, "{}", output);
assert!(output.contains("1.00 (reference)"), "{}", output);
assert!(output.contains("inf (slower)"), "{}", output);
}
Some(_) => assert_eq!(output.matches("N/A").count(), 3, "{}", output),
None => {
assert!(!output.contains("N/A"), "{}", output);
assert!(output.contains("inf"), "{}", output);
assert!(!output.contains("(reference)"), "{}", output);
}
}
}
}
}
}
/// Ensure the makrup output includes the table header and the multiple
+34 -2
View File
@@ -4,7 +4,7 @@ use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::{cmp, env, fmt, io};
use anyhow::ensure;
use anyhow::{bail, ensure};
use clap::ArgMatches;
use crate::command::Commands;
@@ -214,6 +214,12 @@ pub struct Options {
// Name of the reference command
pub reference_name: Option<String>,
// Whether commands are expanded from --parameter-scan or --parameter-list.
pub parameterized_mode: bool,
// Index of the selected command in a parameterized run, if any.
pub parameterized_reference_index: Option<usize>,
/// Command(s) to run before each timing run
pub preparation_command: Option<Vec<String>>,
@@ -257,6 +263,8 @@ impl Default for Options {
command_failure_action: CmdFailureAction::RaiseError,
reference_command: None,
reference_name: None,
parameterized_mode: false,
parameterized_reference_index: None,
preparation_command: None,
conclusion_command: None,
setup_command: None,
@@ -309,6 +317,8 @@ impl Options {
options.setup_command = matches.get_one::<String>("setup").map(String::from);
options.parameterized_mode = matches.get_many::<String>("parameter-scan").is_some()
|| matches.get_many::<String>("parameter-list").is_some();
options.reference_command = matches.get_one::<String>("reference").map(String::from);
options.reference_name = matches
.get_one::<String>("reference-name")
@@ -464,7 +474,29 @@ impl Options {
}
pub fn validate_against_command_list(&mut self, commands: &Commands) -> Result<()> {
let has_reference_command = self.reference_command.is_some();
if self.parameterized_mode {
if let Some(reference) = &self.reference_command {
ensure!(
self.reference_name.is_none(),
"--reference-name cannot be used with a parameterized reference; use --command-name to name the benchmark"
);
let mut matches = commands
.iter()
.enumerate()
.filter(|(_, command)| command.get_name_with_unused_parameters() == *reference);
let Some((index, _)) = matches.next() else {
bail!(
"Reference '{reference}' does not match any parameterized benchmark. Use its full displayed benchmark name."
);
};
ensure!(
matches.next().is_none(),
"Reference '{reference}' matches multiple parameterized benchmarks. Use --command-name to give them unique names."
);
self.parameterized_reference_index = Some(index);
}
}
let has_reference_command = self.reference_command.is_some() && !self.parameterized_mode;
let num_commands = commands.num_commands(has_reference_command);
if let Some(preparation_command) = &self.preparation_command {