diff --git a/README.md b/README.md index ff6020064..f32a27054 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,15 @@ option: hyperfine -L compiler gcc,clang '{compiler} -O2 main.cpp' ``` +For large parameter sets, values can be read one line at a time from a file with +`--parameter-file`: + +``` +hyperfine --parameter-file compiler compilers.txt '{compiler} -O2 main.cpp' +``` +Both LF and CRLF line endings are supported. The file is processed with a buffered reader, so its +full contents are not held in memory. + ### Intermediate shell By default, commands are executed using a predefined shell (`/bin/sh` on Unix, `cmd.exe` on Windows). diff --git a/src/benchmark/scheduler.rs b/src/benchmark/scheduler.rs index 242dd0e7d..a6fcdf97a 100644 --- a/src/benchmark/scheduler.rs +++ b/src/benchmark/scheduler.rs @@ -46,14 +46,25 @@ impl<'a> Scheduler<'a> { executor.calibrate()?; - for (number, cmd) in reference.iter().chain(self.commands.iter()).enumerate() { - self.results - .push(Benchmark::new(number, cmd, self.options, &*executor).run()?); + let mut number = 0; + let results = &mut self.results; + let options = self.options; + let export_manager = self.export_manager; + let mut run_command = |cmd: &Command<'a>| -> Result<()> { + results.push(Benchmark::new(number, cmd, options, &*executor).run()?); // 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)?; + export_manager.write_results(results, true)?; + number += 1; + + Ok(()) + }; + + if let Some(reference) = reference.as_ref() { + run_command(reference)?; } + self.commands.try_for_each(run_command)?; Ok(()) } diff --git a/src/cli.rs b/src/cli.rs index b12f6d34c..d00eb8da5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -200,6 +200,26 @@ fn build_command() -> Command { possible parameter combinations.\n" ), ) + .arg( + Arg::new("parameter-file") + .long("parameter-file") + .action(ArgAction::Set) + .allow_hyphen_values(true) + .value_names(["VAR", "FILE"]) + .conflicts_with_all([ + "parameter-scan", + "parameter-step-size", + "parameter-list", + ]) + .help( + "Perform benchmark runs for each line in FILE. Replaces the string '{VAR}' \ + in each command by the current line. FILE is processed line by line, so its \ + full contents are not held in memory.\n\n Example: hyperfine \ + --parameter-file compiler compilers.txt '{compiler} -O2 main.cpp'\n\nThis \ + performs benchmarks for 'gcc -O2 main.cpp' and 'clang -O2 main.cpp' if FILE \ + contains the lines 'gcc' and 'clang'.", + ), + ) .arg( Arg::new("shell") .long("shell") diff --git a/src/command.rs b/src/command.rs index 5d35cfaf7..35f94e36d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,5 +1,8 @@ use std::collections::BTreeMap; use std::fmt; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; use std::str::FromStr; use crate::parameter::tokenize::tokenize; @@ -131,7 +134,22 @@ impl<'a> Command<'a> { } /// A collection of commands that should be benchmarked -pub struct Commands<'a>(Vec>); +pub struct Commands<'a> { + source: CommandSource<'a>, + len: usize, +} + +enum CommandSource<'a> { + Materialized(Vec>), + ParameterFile(ParameterFileCommands<'a>), +} + +struct ParameterFileCommands<'a> { + parameter_name: &'a str, + path: PathBuf, + command_names: Vec<&'a str>, + command_strings: Vec<&'a str>, +} impl<'a> Commands<'a> { pub fn from_cli_arguments(matches: &'a ArgMatches) -> Result> { @@ -146,12 +164,45 @@ impl<'a> Commands<'a> { let step_size = matches .get_one::("parameter-step-size") .map(|s| s.as_str()); - Ok(Self(Self::get_parameter_scan_commands( - command_names, - command_strings, - args, - step_size, - )?)) + let commands = + Self::get_parameter_scan_commands(command_names, command_strings, args, step_size)?; + let len = commands.len(); + Ok(Self { + source: CommandSource::Materialized(commands), + len, + }) + } else if let Some(mut args) = matches.get_many::("parameter-file") { + let parameter_name = args.next().unwrap().as_str(); + let path = PathBuf::from(args.next().unwrap().as_str()); + let line_count = Self::count_parameter_file_lines(&path)?; + let len = command_strings + .len() + .checked_mul(line_count) + .ok_or_else(|| { + anyhow::anyhow!("The parameter file produces too many benchmark commands") + })?; + let command_names = command_names.map_or(vec![], |names| { + names.map(|v| v.as_str()).collect::>() + }); + + // `--command-name` should appear exactly once or exactly B times, + // where B is the total number of benchmarks. + let command_name_count = command_names.len(); + if command_name_count > 1 && command_name_count != len { + return Err( + OptionsError::UnexpectedCommandNameCount(command_name_count, len).into(), + ); + } + + Ok(Self { + source: CommandSource::ParameterFile(ParameterFileCommands { + parameter_name, + path, + command_names, + command_strings, + }), + len, + }) } else if let Some(args) = matches.get_many::("parameter-list") { let command_names = command_names.map_or(vec![], |names| { names.map(|v| v.as_str()).collect::>() @@ -182,7 +233,10 @@ impl<'a> Commands<'a> { .collect(); let param_space_size = dimensions.iter().product(); if param_space_size == 0 { - return Ok(Self(Vec::new())); + return Ok(Self { + source: CommandSource::Materialized(Vec::new()), + len: 0, + }); } // `--command-name` should appear exactly once or exactly B times, @@ -230,7 +284,11 @@ impl<'a> Commands<'a> { break 'outer; } - Ok(Self(commands)) + let len = commands.len(); + Ok(Self { + source: CommandSource::Materialized(commands), + len, + }) } else { let command_names = command_names.map_or(vec![], |names| { names.map(|v| v.as_str()).collect::>() @@ -243,16 +301,102 @@ impl<'a> Commands<'a> { for (i, s) in command_strings.iter().enumerate() { commands.push(Command::new(command_names.get(i).copied(), s)); } - Ok(Self(commands)) + let len = commands.len(); + Ok(Self { + source: CommandSource::Materialized(commands), + len, + }) } } - pub fn iter(&self) -> impl Iterator> { - self.0.iter() + /// Calls `f` for each command without materializing parameter-file values in memory. + pub fn try_for_each(&self, mut f: F) -> Result<()> + where + F: FnMut(&Command<'a>) -> Result<()>, + { + match &self.source { + CommandSource::Materialized(commands) => { + for command in commands { + f(command)?; + } + } + CommandSource::ParameterFile(parameter_file) => { + let file = File::open(¶meter_file.path).with_context(|| { + format!( + "Failed to open parameter file '{}'", + parameter_file.path.display() + ) + })?; + let mut benchmark_index = 0; + + for line in BufReader::new(file).lines() { + let line = line.with_context(|| { + format!( + "Failed to read parameter file '{}'", + parameter_file.path.display() + ) + })?; + + for command_string in ¶meter_file.command_strings { + let name = parameter_file + .command_names + .get(benchmark_index) + .or_else(|| parameter_file.command_names.first()) + .copied(); + let command = Command::new_parametrized( + name, + command_string, + [( + parameter_file.parameter_name, + ParameterValue::Text(line.clone()), + )], + ); + f(&command)?; + benchmark_index += 1; + } + } + } + } + + Ok(()) } pub fn num_commands(&self, has_reference_command: bool) -> usize { - self.0.len() + if has_reference_command { 1 } else { 0 } + self.len + if has_reference_command { 1 } else { 0 } + } + + fn count_parameter_file_lines(path: &Path) -> Result { + let file = File::open(path) + .with_context(|| format!("Failed to open parameter file '{}'", path.display()))?; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut count = 0usize; + + loop { + buffer.clear(); + if reader + .read_until(b'\n', &mut buffer) + .with_context(|| format!("Failed to read parameter file '{}'", path.display()))? + == 0 + { + break; + } + count = count + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("The parameter file contains too many lines"))?; + } + + Ok(count) + } + + #[cfg(test)] + fn materialized(&self) -> &[Command<'a>] { + match &self.source { + CommandSource::Materialized(commands) => commands, + CommandSource::ParameterFile(_) => { + panic!("parameter-file commands are generated lazily") + } + } } /// Finds all the strings that appear multiple times in the input iterator, returning them in @@ -405,7 +549,8 @@ fn test_build_commands_cross_product() { "echo {par1} {par2}", "printf '%s\n' {par1} {par2}", ]); - let result = Commands::from_cli_arguments(&matches).unwrap().0; + let commands = Commands::from_cli_arguments(&matches).unwrap(); + let result = commands.materialized(); // Iteration order: command list first, then parameters in listed order (here, "par1" before // "par2", which is distinct from their sorted order), with parameter values in listed order. @@ -441,7 +586,8 @@ fn test_build_parameter_list_commands() { "--command-name", "name-{foo}", ]); - let commands = Commands::from_cli_arguments(&matches).unwrap().0; + let command_source = Commands::from_cli_arguments(&matches).unwrap(); + let commands = command_source.materialized(); assert_eq!(commands.len(), 2); assert_eq!(commands[0].get_name(), "name-1"); assert_eq!(commands[1].get_name(), "name-2"); @@ -449,6 +595,82 @@ fn test_build_parameter_list_commands() { assert_eq!(commands[1].get_command_line(), "echo 2"); } +#[test] +fn test_build_parameter_file_commands_lazily() { + use crate::cli::get_cli_arguments; + use std::io::Write; + + let mut parameters = tempfile::NamedTempFile::new().unwrap(); + parameters.write_all(b"alpha\r\nbeta\nlast").unwrap(); + + let matches = get_cli_arguments(vec![ + "hyperfine", + "echo {value}", + "print {value}", + "--parameter-file", + "value", + parameters.path().to_str().unwrap(), + ]); + let commands = Commands::from_cli_arguments(&matches).unwrap(); + assert_eq!(commands.num_commands(false), 6); + + let mut command_lines = Vec::new(); + commands + .try_for_each(|command| { + command_lines.push(command.get_command_line()); + Ok(()) + }) + .unwrap(); + + assert_eq!( + command_lines, + [ + "echo alpha", + "print alpha", + "echo beta", + "print beta", + "echo last", + "print last", + ] + ); +} + +#[test] +fn test_parameter_file_large_input_is_processed_line_by_line() { + use crate::cli::get_cli_arguments; + use std::io::Write; + + let mut parameters = tempfile::NamedTempFile::new().unwrap(); + for i in 0..10_000 { + writeln!(parameters, "{i}").unwrap(); + } + + let matches = get_cli_arguments(vec![ + "hyperfine", + "echo {value}", + "--parameter-file", + "value", + parameters.path().to_str().unwrap(), + ]); + let commands = Commands::from_cli_arguments(&matches).unwrap(); + assert_eq!(commands.num_commands(false), 10_000); + + let mut seen = 0; + commands + .try_for_each(|command| { + if seen == 0 { + assert_eq!(command.get_command_line(), "echo 0"); + } + if seen == 9_999 { + assert_eq!(command.get_command_line(), "echo 9999"); + } + seen += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(seen, 10_000); +} + #[test] fn test_build_parameter_scan_commands() { use crate::cli::get_cli_arguments; @@ -464,7 +686,8 @@ fn test_build_parameter_scan_commands() { "--command-name", "name-{val}", ]); - let commands = Commands::from_cli_arguments(&matches).unwrap().0; + let command_source = Commands::from_cli_arguments(&matches).unwrap(); + let commands = command_source.materialized(); assert_eq!(commands.len(), 2); assert_eq!(commands[0].get_name(), "name-1"); assert_eq!(commands[1].get_name(), "name-2"); @@ -494,7 +717,8 @@ fn test_build_parameter_scan_commands_named() { "--command-name", "sleep-2", ]); - let commands = Commands::from_cli_arguments(&matches).unwrap().0; + let command_source = Commands::from_cli_arguments(&matches).unwrap(); + let commands = command_source.materialized(); assert_eq!(commands.len(), 4); assert_eq!(commands[0].get_name(), "echo-1"); assert_eq!(commands[0].get_command_line(), "echo 1"); diff --git a/tests/execution_order_tests.rs b/tests/execution_order_tests.rs index 6ccc2e5ed..187e43497 100644 --- a/tests/execution_order_tests.rs +++ b/tests/execution_order_tests.rs @@ -1,6 +1,10 @@ -use std::{fs::File, io::Read, path::PathBuf}; +use std::{ + fs::File, + io::{Read, Write}, + path::PathBuf, +}; -use tempfile::{tempdir, TempDir}; +use tempfile::{tempdir, NamedTempFile, TempDir}; mod common; use common::hyperfine; @@ -370,6 +374,26 @@ fn multiple_parameter_values() { .run(); } +#[test] +fn parameter_file_values() { + let mut parameters = NamedTempFile::new().unwrap(); + parameters.write_all(b"one\r\ntwo\nthree").unwrap(); + + ExecutionOrderTest::new() + .arg("--runs=2") + .arg("--parameter-file") + .arg("value") + .arg(parameters.path().to_str().unwrap()) + .command("command {value}") + .expect_output("command one") + .expect_output("command one") + .expect_output("command two") + .expect_output("command two") + .expect_output("command three") + .expect_output("command three") + .run(); +} + #[test] fn reference_is_executed_first() { ExecutionOrderTest::new()