Export selected metrics side by side in CSV

This commit is contained in:
David Peter committed 2026-10-07 21:17:25 +02:00
1 parent 7178fedde1
commit 7cf08c30dc
10 files changed
+101 -70

No files matched your search

+11 -2
View File
@@ -239,8 +239,9 @@ You can also use a preset to select a group of metrics:
### Exporting results ### Exporting results
Hyperfine can export results to CSV, JSON, Markdown, AsciiDoc, and org-mode. Non-JSON formats Hyperfine can export results to CSV, JSON, Markdown, AsciiDoc, and org-mode. CSV exports all
contain only the primary metric, which is the first metric selected by `--metrics`. selected metrics, while Markdown, AsciiDoc, and org-mode contain only the primary metric,
which is the first metric selected by `--metrics`.
#### Markdown #### Markdown
@@ -276,6 +277,14 @@ uvx --from 'git+https://github.com/sharkdp/hyperfine' \
|---:|---:| |---:|---:|
#### CSV
Use `--export-csv <file>` to export one row per command. Each metric selected by `--metrics`
contributes a group of columns, in selection order: `<metric>_unit`, `<metric>_mean`,
`<metric>_stddev`, `<metric>_median`, `<metric>_min`, and `<metric>_max`.
CSV uses explicitly selected units, or base units (seconds, bytes, or counts) when omitted.
### Detailed benchmark flowchart ### Detailed benchmark flowchart
The following chart explains the execution order of various benchmark runs when using options The following chart explains the execution order of various benchmark runs when using options
+6 -4
View File
@@ -232,7 +232,7 @@ of the tool.
.HP .HP
\fB\-\-metrics\fR \fIMETRIC[:UNIT],...|default|time|all\fP \fB\-\-metrics\fR \fIMETRIC[:UNIT],...|default|time|all\fP
.IP .IP
Select the metrics to display, in order, with an optional unit for each. Select the metrics to measure, in order, with an optional unit for each.
Default: \fBtime_wall_clock,memory_peak_resident\fR, or \fBtime_wall_clock\fR Default: \fBtime_wall_clock,memory_peak_resident\fR, or \fBtime_wall_clock\fR
on Windows, where peak resident memory is not yet supported. on Windows, where peak resident memory is not yet supported.
.IP .IP
@@ -266,9 +266,11 @@ Export the primary metric's summary statistics as an AsciiDoc table to the given
.HP .HP
\fB\-\-export\-csv\fR \fIFILE\fP \fB\-\-export\-csv\fR \fIFILE\fP
.IP .IP
Export the primary metric's summary statistics as CSV to the given \fIFILE\fP, Export summary statistics for all selected metrics as CSV to the given \fIFILE\fP,
identifying the metric and unit explicitly. Use the explicitly selected unit, one row per command. Each metric contributes columns for unit, mean, stddev, median,
or the base unit (seconds, bytes, or unscaled counts) when omitted. min, and max, prefixed with the metric name, in selection order. The command column
comes first and parameter columns come last. Use the explicitly selected units,
or base units (seconds, bytes, or unscaled counts) when omitted.
.HP .HP
\fB\-\-export\-json\fR \fIFILE\fP \fB\-\-export\-json\fR \fIFILE\fP
.IP .IP
+1 -1
View File
@@ -69,7 +69,7 @@ fn generate_results(args: &[&'static str]) -> Result<Vec<BenchmarkResult>> {
assert_eq!(options.executor_kind, ExecutorKind::Mock(None)); assert_eq!(options.executor_kind, ExecutorKind::Mock(None));
let commands = Commands::from_cli_arguments(&cli_arguments)?; let commands = Commands::from_cli_arguments(&cli_arguments)?;
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, options.metrics[0])?; let export_manager = ExportManager::from_cli_arguments(&cli_arguments, &options.metrics)?;
options.validate_against_command_list(&commands)?; options.validate_against_command_list(&commands)?;
+4 -3
View File
@@ -58,7 +58,7 @@ fn build_command() -> Command {
.hide_default_value(true) .hide_default_value(true)
.help("Performance metrics to measure, in order.\n\n\ .help("Performance metrics to measure, in order.\n\n\
This is either a preset name, or a comma-separated list of metrics with optional units. \ This is either a preset name, or a comma-separated list of metrics with optional units. \
The first metric is used for non-JSON exports, \ CSV exports all selected metrics; markup exports use the first metric. \
JSON always includes all collected metrics.\n\n\ JSON always includes all collected metrics.\n\n\
Presets (use one on its own):\n \ Presets (use one on its own):\n \
default Wall-clock time and peak RSS\n \ default Wall-clock time and peak RSS\n \
@@ -303,9 +303,10 @@ fn build_command() -> Command {
.action(ArgAction::Set) .action(ArgAction::Set)
.value_name("FILE") .value_name("FILE")
.value_hint(ValueHint::FilePath) .value_hint(ValueHint::FilePath)
.help("Export the primary metric summary statistics as CSV to the given FILE. If you need \ .help("Export summary statistics for all selected metrics as CSV to the given FILE. If you need \
all metrics for each individual run, use the JSON export format. \ all metrics for each individual run, use the JSON export format. \
Use the explicitly selected unit, or the base unit when unspecified."), Column names are prefixed with the metric name. \
Use the explicitly selected units, or base units when unspecified."),
) )
.arg( .arg(
Arg::new("export-json") Arg::new("export-json")
+54 -49
View File
@@ -4,23 +4,34 @@ use super::Exporter;
use crate::benchmark::benchmark_result::BenchmarkResult; use crate::benchmark::benchmark_result::BenchmarkResult;
use crate::metric::MetricSelection; use crate::metric::MetricSelection;
use anyhow::{Context, Result}; use anyhow::Result;
#[derive(Default)] #[derive(Default)]
pub struct CsvExporter {} pub struct CsvExporter {}
impl Exporter for CsvExporter { impl Exporter for CsvExporter {
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>> { fn serialize(
// CSV never chooses units automatically: an omitted suffix means base units. &self,
let unit = primary.csv_unit(); results: &[BenchmarkResult],
metrics: &[MetricSelection],
) -> Result<Vec<u8>> {
// Omit metrics unavailable for all commands (possible with `--metrics all`).
let metrics: Vec<_> = metrics
.iter()
.filter(|selection| {
results
.iter()
.any(|result| selection.metric.summarize(&result.measurements).is_some())
})
.collect();
let mut writer = WriterBuilder::new().from_writer(vec![]); let mut writer = WriterBuilder::new().from_writer(vec![]);
let mut headers: Vec<String> = [ let mut headers = vec!["command".to_owned()];
"command", "metric", "unit", "mean", "stddev", "median", "min", "max", for selection in &metrics {
] headers.extend(
.iter() ["unit", "mean", "stddev", "median", "min", "max"]
.copied() .map(|field| format!("{}_{field}", selection.metric.name())),
.map(str::to_owned) );
.collect(); }
if let Some(result) = results.first() { if let Some(result) = results.first() {
headers.extend( headers.extend(
result result
@@ -32,36 +43,28 @@ impl Exporter for CsvExporter {
writer.write_record(headers)?; writer.write_record(headers)?;
for result in results { for result in results {
let stats = primary let mut fields = vec![result.get_name().to_owned()];
.metric for selection in &metrics {
.summarize(&result.measurements) // CSV never chooses units automatically: an omitted suffix means base units.
.with_context(|| { let unit = selection.csv_unit();
format!( let stats = selection.metric.summarize(&result.measurements);
"Metric '{}' is unavailable for '{}'", fields.push(unit.symbol.to_owned());
primary.metric.name(), // Preserve numeric precision rather than using rounded display values.
result.get_name() fields.extend(
) [
})?; stats.map(|stats| stats.mean),
let mut fields = vec![ stats.and_then(|stats| stats.stddev),
result.get_name().to_owned(), stats.map(|stats| stats.median),
primary.metric.name().to_owned(), stats.map(|stats| stats.min),
unit.symbol.to_owned(), stats.map(|stats| stats.max),
]; ]
// Preserve numeric precision rather than using rounded display values. .map(|value| {
fields.extend( value
[ .map(|value| (value / unit.scale).to_string())
Some(stats.mean), .unwrap_or_default()
stats.stddev, }),
Some(stats.median), );
Some(stats.min), }
Some(stats.max),
]
.map(|value| {
value
.map(|value| (value / unit.scale).to_string())
.unwrap_or_default()
}),
);
fields.extend( fields.extend(
result result
.parameters .parameters
@@ -191,15 +194,18 @@ fn test_csv() {
let actual = String::from_utf8( let actual = String::from_utf8(
exporter exporter
.serialize(&results, MetricSelection::default()) .serialize(
&results,
&MetricSelection::parse_list("time_wall_clock,memory_peak_resident").unwrap(),
)
.unwrap(), .unwrap(),
) )
.unwrap(); .unwrap();
insta::assert_snapshot!(actual, @r#" insta::assert_snapshot!(actual, @r#"
command,metric,unit,mean,stddev,median,min,max,parameter_bar,parameter_foo command,time_wall_clock_unit,time_wall_clock_mean,time_wall_clock_stddev,time_wall_clock_median,time_wall_clock_min,time_wall_clock_max,memory_peak_resident_unit,memory_peak_resident_mean,memory_peak_resident_stddev,memory_peak_resident_median,memory_peak_resident_min,memory_peak_resident_max,parameter_bar,parameter_foo
command_a,time_wall_clock,s,9,2.6457513110645907,8,7,12,two,one command_a,s,9,2.6457513110645907,8,7,12,B,1024,0,1024,1024,1024,two,one
command_b,time_wall_clock,s,18,1,18,17,19,seven,one command_b,s,18,1,18,17,19,B,1024,0,1024,1024,1024,seven,one
"#); "#);
for (selection, unit, mean) in [ for (selection, unit, mean) in [
@@ -207,12 +213,11 @@ fn test_csv() {
("memory_peak_resident:KiB", "KiB", "1"), ("memory_peak_resident:KiB", "KiB", "1"),
] { ] {
let output = exporter let output = exporter
.serialize(&results, MetricSelection::parse_list(selection).unwrap()[0]) .serialize(&results, &MetricSelection::parse_list(selection).unwrap())
.unwrap(); .unwrap();
let mut reader = csv::Reader::from_reader(output.as_slice()); let mut reader = csv::Reader::from_reader(output.as_slice());
let row = reader.records().next().unwrap().unwrap(); let row = reader.records().next().unwrap().unwrap();
assert_eq!(&row[1], "memory_peak_resident"); assert_eq!(&row[1], unit);
assert_eq!(&row[2], unit); assert_eq!(&row[2], mean);
assert_eq!(&row[3], mean);
} }
} }
+6 -1
View File
@@ -65,7 +65,12 @@ pub struct JsonExporter {
} }
impl Exporter for JsonExporter { impl Exporter for JsonExporter {
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>> { fn serialize(
&self,
results: &[BenchmarkResult],
metrics: &[MetricSelection],
) -> Result<Vec<u8>> {
let primary = metrics[0];
let mut output = to_vec_pretty(&HyperfineSummary { let mut output = to_vec_pretty(&HyperfineSummary {
schema_version: JSON_SCHEMA_VERSION, schema_version: JSON_SCHEMA_VERSION,
metadata: &self.metadata, metadata: &self.metadata,
+6 -1
View File
@@ -77,7 +77,12 @@ pub trait MarkupExporter {
} }
impl<T: MarkupExporter> Exporter for T { impl<T: MarkupExporter> Exporter for T {
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>> { fn serialize(
&self,
results: &[BenchmarkResult],
metrics: &[MetricSelection],
) -> Result<Vec<u8>> {
let primary = metrics[0];
let stats: Vec<_> = results let stats: Vec<_> = results
.iter() .iter()
.map(|result| { .map(|result| {
+10 -6
View File
@@ -44,8 +44,12 @@ pub enum ExportType {
/// Interface for different exporters. /// Interface for different exporters.
trait Exporter { trait Exporter {
/// Export the given entries in the serialized form. /// Export the given entries using a nonempty list of selected metrics, primary first.
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>>; fn serialize(
&self,
results: &[BenchmarkResult],
metrics: &[MetricSelection],
) -> Result<Vec<u8>>;
} }
pub enum ExportTarget { pub enum ExportTarget {
@@ -61,16 +65,16 @@ struct ExporterWithTarget {
/// Handles the management of multiple file exporters. /// Handles the management of multiple file exporters.
pub struct ExportManager { pub struct ExportManager {
exporters: Vec<ExporterWithTarget>, exporters: Vec<ExporterWithTarget>,
primary: MetricSelection, metrics: Vec<MetricSelection>,
} }
impl ExportManager { impl ExportManager {
/// Build the ExportManager that will export the results specified /// Build the ExportManager that will export the results specified
/// in the given ArgMatches /// in the given ArgMatches
pub fn from_cli_arguments(matches: &ArgMatches, primary: MetricSelection) -> Result<Self> { pub fn from_cli_arguments(matches: &ArgMatches, metrics: &[MetricSelection]) -> Result<Self> {
let mut export_manager = Self { let mut export_manager = Self {
exporters: vec![], exporters: vec![],
primary, metrics: metrics.to_vec(),
}; };
{ {
let mut add_exporter = |flag, exporttype| -> Result<()> { let mut add_exporter = |flag, exporttype| -> Result<()> {
@@ -120,7 +124,7 @@ impl ExportManager {
/// order not to clutter the output of hyperfine with intermediate results). /// order not to clutter the output of hyperfine with intermediate results).
pub fn write_results(&self, results: &[BenchmarkResult], intermediate: bool) -> Result<()> { pub fn write_results(&self, results: &[BenchmarkResult], intermediate: bool) -> Result<()> {
for e in &self.exporters { for e in &self.exporters {
let content = || e.exporter.serialize(results, self.primary); let content = || e.exporter.serialize(results, &self.metrics);
match e.target { match e.target {
ExportTarget::File(ref filename) => { ExportTarget::File(ref filename) => {
+2 -2
View File
@@ -15,10 +15,10 @@ fn get_output<E: Exporter + Default>(results: &[BenchmarkResult], unit: Option<U
exporter exporter
.serialize( .serialize(
results, results,
MetricSelection { &[MetricSelection {
unit, unit,
..MetricSelection::default() ..MetricSelection::default()
}, }],
) )
.unwrap(), .unwrap(),
) )
+1 -1
View File
@@ -62,7 +62,7 @@ fn run() -> Result<()> {
} }
} }
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, options.metrics[0])?; let export_manager = ExportManager::from_cli_arguments(&cli_arguments, &options.metrics)?;
let mut scheduler = Scheduler::new(&commands, &options, &export_manager); let mut scheduler = Scheduler::new(&commands, &options, &export_manager);
scheduler.run_benchmarks()?; scheduler.run_benchmarks()?;