Add --env for benchmark environment overrides

This commit is contained in:
David Peter authored and David Peter committed 2026-10-07 22:32:37 +02:00
1 parent 067ee70f20
commit 6dddb02d73
12 files changed
+292 -15

No files matched your search

+25
View File
@@ -155,6 +155,31 @@ Otherwise, inline the function into the benchmarked command:
hyperfine -S 'my_function() { sleep 1; }; my_function' hyperfine -S 'my_function() { sleep 1; }; my_function'
``` ```
### Environment variables
Use `--env` to set environment variables for benchmark commands and their respective setup, prepare, conclude,
and cleanup commands. Assignments are space-separated and support shell-style quoting,
but no shell expansion:
```sh
hyperfine --env 'OMP_NUM_THREADS=8 CFLAGS="-O3 -march=native"' 'my_command'
```
Specify once for all commands or once per benchmark command:
```sh
hyperfine \
--env 'OMP_NUM_THREADS=8' './benchmark-cpp' \
--env 'RAYON_NUM_THREADS=8' './benchmark-rust'
```
Use an empty `--env=''` for no overrides. Values can contain parameter placeholders.
For example, the following benchmarks `my_command` with `OMP_NUM_THREADS` ranging from 1 to 8:
```sh
hyperfine -P threads 1 8 --env 'OMP_NUM_THREADS={threads}' 'my_command'
```
### Choosing metrics and units ### Choosing metrics and units
By default, hyperfine displays wall-clock time and memory usage By default, hyperfine displays wall-clock time and memory usage
+11
View File
@@ -192,6 +192,17 @@ This performs benchmarks for 'g++ \-O2 main.cpp' and 'clang++ \-O2 main.cpp'.
The option can be specified multiple times to run benchmarks for all The option can be specified multiple times to run benchmarks for all
possible parameter combinations. possible parameter combinations.
.HP .HP
\fB\-\-env\fR \fIASSIGNMENTS\fP
.IP
Set environment variables for benchmark commands and their respective setup, prepare, conclude,
and cleanup commands. Assignments are space-separated and support shell-style quoting,
but no shell expansion, e.g. \fB\-\-env\fR 'OMP_NUM_THREADS=8 CFLAGS="\-O3 \-march=native"'.
Specify once for all commands or once per benchmark command, like \fB\-\-prepare\fR.
Use an empty string for no overrides. Values can contain parameter placeholders.
.IP
For example, \fBhyperfine \-P threads 1 8 \-\-env\fR 'OMP_NUM_THREADS={threads}' 'my_command'
benchmarks 'my_command' with OMP_NUM_THREADS ranging from 1 to 8.
.HP
\fB\-\-shell\fR \fISHELL\fP \fB\-\-shell\fR \fISHELL\fP
.IP .IP
Set the shell to use for executing benchmarked commands, including warmup runs Set the shell to use for executing benchmarked commands, including warmup runs
+8 -6
View File
@@ -18,15 +18,13 @@ pub struct BenchmarkResult {
/// regardless of any custom name. /// regardless of any custom name.
pub command: String, pub command: String,
/// The custom name after parameter substitution, if it differs from `command`. /// The custom or automatically generated name after parameter substitution,
/// For example, `--command-name="wait {duration}s"` with `duration=1` /// if it differs from `command`. Automatic names include environment overrides.
/// produces `Some("wait 1s")`. Without a custom name, or when the expanded
/// name equals `command`, this is `None`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
/// The custom name (or `command`), followed by parameters not used in the /// The name, followed by parameters not used in the command or environment
/// command template, in command-line order. For example, `sleep {duration}` /// templates, in command-line order. For example, `sleep {duration}`
/// with `duration=1`, `branch=main`, and the custom name `wait {duration}s` /// with `duration=1`, `branch=main`, and the custom name `wait {duration}s`
/// produces `wait 1s (branch = main)`. Without the custom name, this is /// produces `wait 1s (branch = main)`. Without the custom name, this is
/// `sleep 1 (branch = main)`. Used in summaries and table exports. /// `sleep 1 (branch = main)`. Used in summaries and table exports.
@@ -40,6 +38,10 @@ pub struct BenchmarkResult {
/// Parameter values for this benchmark /// Parameter values for this benchmark
#[serde(skip_serializing_if = "BTreeMap::is_empty")] #[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub parameters: BTreeMap<String, Parameter>, pub parameters: BTreeMap<String, Parameter>,
/// Explicit environment overrides after parameter substitution.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub environment: BTreeMap<String, String>,
} }
impl BenchmarkResult { impl BenchmarkResult {
+1
View File
@@ -215,6 +215,7 @@ impl Executor for ShellExecutor<'_> {
) -> Result<Measurement> { ) -> Result<Measurement> {
let on_windows_cmd = cfg!(windows) && *self.shell == Shell::Default("cmd.exe"); let on_windows_cmd = cfg!(windows) && *self.shell == Shell::Default("cmd.exe");
let mut command_builder = self.shell.command(); let mut command_builder = self.shell.command();
command_builder.envs(command.get_environment());
command_builder.arg(if on_windows_cmd { "/C" } else { "-c" }); command_builder.arg(if on_windows_cmd { "/C" } else { "-c" });
// Windows needs special treatment for its behavior on parsing cmd arguments // Windows needs special treatment for its behavior on parsing cmd arguments
+7 -1
View File
@@ -61,8 +61,9 @@ impl<'a> Benchmark<'a> {
output_policy: &CommandOutputPolicy, output_policy: &CommandOutputPolicy,
iteration: executor::BenchmarkIteration, iteration: executor::BenchmarkIteration,
) -> Result<Measurement> { ) -> Result<Measurement> {
let command = command.clone().with_environment_from(self.command);
self.executor self.executor
.run_intermediate_command_and_measure(command, iteration, output_policy) .run_intermediate_command_and_measure(&command, iteration, output_policy)
.map_err(|error| { .map_err(|error| {
let hint = if cfg!(windows) { let hint = if cfg!(windows) {
"Append ' || exit /b 0' to the command if this failure can be ignored." "Append ' || exit /b 0' to the command if this failure can be ignored."
@@ -443,6 +444,11 @@ impl<'a> Benchmark<'a> {
Ok(BenchmarkResult { Ok(BenchmarkResult {
command, command,
name, name,
environment: self
.command
.get_environment()
.map(|(name, value)| (name.to_owned(), value))
.collect(),
display_name: self.command.get_name_with_unused_parameters(), display_name: self.command.get_name_with_unused_parameters(),
measurements, measurements,
parameters: self parameters: self
+22
View File
@@ -119,6 +119,28 @@ fn build_command() -> Command {
.help("Perform exactly NUM runs for each command. If this option is not specified, \ .help("Perform exactly NUM runs for each command. If this option is not specified, \
hyperfine automatically determines the number of runs."), hyperfine automatically determines the number of runs."),
) )
.arg(
Arg::new("env")
.long("env")
.action(ArgAction::Append)
.num_args(1)
.value_name("ASSIGNMENTS")
.help("Set environment variables for benchmark commands and their respective setup, \
prepare, conclude, and cleanup commands. Assignments are space-separated and \
support shell-style quoting, but no shell expansion:\n\n \
hyperfine \\\n \
--env 'OMP_NUM_THREADS=8 CFLAGS=\"-O3 -march=native\"' \\\n \
'my_command'\n\n\
Specify once for all commands or once per benchmark command:\n\n \
hyperfine \\\n \
--env 'OMP_NUM_THREADS=8' './benchmark-cpp' \\\n \
--env 'RAYON_NUM_THREADS=8' './benchmark-rust'\n\n\
Use an empty --env='' for no overrides. Values can contain parameter placeholders. \
For example, the following benchmarks 'my_command' with OMP_NUM_THREADS ranging \
from 1 to 8:\n\n \
hyperfine -P threads 1 8 \\\n \
--env 'OMP_NUM_THREADS={threads}' 'my_command'"),
)
.arg( .arg(
Arg::new("setup") Arg::new("setup")
.long("setup") .long("setup")
+91 -6
View File
@@ -14,9 +14,11 @@ use crate::{
use clap::{parser::ValuesRef, ArgMatches}; use clap::{parser::ValuesRef, ArgMatches};
use anyhow::{bail, Context, Result}; use anyhow::{bail, ensure, Context, Result};
use rust_decimal::Decimal; use rust_decimal::Decimal;
type Environment = Vec<(String, String)>;
/// A command that should be benchmarked. /// A command that should be benchmarked.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command<'a> { pub struct Command<'a> {
@@ -28,6 +30,9 @@ pub struct Command<'a> {
/// Zero or more parameter values. /// Zero or more parameter values.
parameters: Vec<ParameterNameAndValue<'a>>, parameters: Vec<ParameterNameAndValue<'a>>,
/// Environment overrides, before parameter substitution.
environment: Environment,
} }
impl<'a> Command<'a> { impl<'a> Command<'a> {
@@ -36,6 +41,7 @@ impl<'a> Command<'a> {
name, name,
expression, expression,
parameters: Vec::new(), parameters: Vec::new(),
environment: Vec::new(),
} }
} }
@@ -48,14 +54,26 @@ impl<'a> Command<'a> {
name, name,
expression, expression,
parameters: parameters.into_iter().collect(), parameters: parameters.into_iter().collect(),
environment: Vec::new(),
} }
} }
pub fn get_name(&self) -> String { pub fn get_name(&self) -> String {
self.name.map_or_else( match self.name {
|| self.get_command_line(), Some(name) => self.replace_parameters_in(name),
|name| self.replace_parameters_in(name), None => {
) let environment = self
.get_environment()
.map(|(name, value)| format!("{name}={}", shell_words::quote(&value)))
.collect::<Vec<_>>()
.join(" ");
if environment.is_empty() {
self.get_command_line()
} else {
format!("{environment} {}", self.get_command_line())
}
}
}
} }
pub fn get_name_with_unused_parameters(&self) -> String { pub fn get_name_with_unused_parameters(&self) -> String {
@@ -87,6 +105,7 @@ impl<'a> Command<'a> {
if let Some(program_name) = tokens.next() { if let Some(program_name) = tokens.next() {
let mut command_builder = std::process::Command::new(program_name); let mut command_builder = std::process::Command::new(program_name);
command_builder.args(tokens); command_builder.args(tokens);
command_builder.envs(self.get_environment());
Ok(command_builder) Ok(command_builder)
} else { } else {
bail!("Can not execute empty command") bail!("Can not execute empty command")
@@ -97,8 +116,24 @@ impl<'a> Command<'a> {
&self.parameters &self.parameters
} }
pub fn get_environment(&self) -> impl Iterator<Item = (&str, String)> {
self.environment
.iter()
.map(move |(name, value)| (name.as_str(), self.replace_parameters_in(value)))
}
pub fn with_environment_from(mut self, command: &Command<'_>) -> Self {
self.environment = command.environment.clone();
self
}
pub fn is_parameter_unused(&self, parameter: &str) -> bool { pub fn is_parameter_unused(&self, parameter: &str) -> bool {
!self.expression.contains(&format!("{{{parameter}}}")) let placeholder = format!("{{{parameter}}}");
!self.expression.contains(&placeholder)
&& !self
.environment
.iter()
.any(|(_, value)| value.contains(&placeholder))
} }
pub fn get_unused_parameters(&self) -> impl Iterator<Item = &(&'a str, ParameterValue)> { pub fn get_unused_parameters(&self) -> impl Iterator<Item = &(&'a str, ParameterValue)> {
@@ -137,8 +172,58 @@ impl<'a> Command<'a> {
/// A collection of commands that should be benchmarked /// A collection of commands that should be benchmarked
pub struct Commands<'a>(Vec<Command<'a>>); pub struct Commands<'a>(Vec<Command<'a>>);
fn parse_environment(value: &str) -> Result<Environment> {
let mut environment = Environment::new();
for assignment in shell_words::split(value).context("Failed to parse '--env' assignments")? {
let (name, value) = assignment.split_once('=').with_context(|| {
format!(
"Invalid value '{assignment}' for '--env': expected an assignment like NAME=value or NAME=\"quoted value\"."
)
})?;
ensure!(
!name.is_empty()
&& name.bytes().enumerate().all(|(index, byte)| {
byte == b'_'
|| byte.is_ascii_alphabetic()
|| (index > 0 && byte.is_ascii_digit())
}),
"Invalid environment variable name '{name}' in '--env'"
);
// Keep only the last assignment, including case variants on Windows.
environment.retain(|(key, _)| {
if cfg!(windows) {
!key.eq_ignore_ascii_case(name)
} else {
key != name
}
});
environment.push((name.to_owned(), value.to_owned()));
}
Ok(environment)
}
impl<'a> Commands<'a> { impl<'a> Commands<'a> {
pub fn from_cli_arguments(matches: &'a ArgMatches) -> Result<Commands<'a>> { pub fn from_cli_arguments(matches: &'a ArgMatches) -> Result<Commands<'a>> {
let mut commands = Self::build_from_cli_arguments(matches)?;
if let Some(values) = matches.get_many::<String>("env") {
let environments = values
.map(|value| parse_environment(value))
.collect::<Result<Vec<_>>>()?;
let num_commands = commands.num_commands();
ensure!(
environments.len() == 1 || environments.len() == num_commands,
"The '--env' option has to be provided just once or N times, where N={num_commands} is the \
number of benchmark commands."
);
for (index, command) in commands.0.iter_mut().enumerate() {
command.environment =
environments[if environments.len() == 1 { 0 } else { index }].clone();
}
}
Ok(commands)
}
fn build_from_cli_arguments(matches: &'a ArgMatches) -> Result<Commands<'a>> {
let command_names = matches.get_many::<String>("command-name"); let command_names = matches.get_many::<String>("command-name");
let command_strings = matches let command_strings = matches
.get_many::<String>("command") .get_many::<String>("command")
+2
View File
@@ -91,6 +91,7 @@ fn test_csv() {
let results = vec![ let results = vec![
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("echo command_a"), command: String::from("echo command_a"),
name: Some(String::from("command_a")), name: Some(String::from("command_a")),
display_name: String::from("command_a"), display_name: String::from("command_a"),
@@ -141,6 +142,7 @@ fn test_csv() {
}, },
}, },
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("command_b"), command: String::from("command_b"),
name: None, name: None,
display_name: String::from("command_b"), display_name: String::from("command_b"),
+8
View File
@@ -35,6 +35,7 @@ fn get_output<E: Exporter + Default>(results: &[BenchmarkResult], unit: Option<U
fn test_markup_export_auto_ms() { fn test_markup_export_auto_ms() {
let results = [ let results = [
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"), command: String::from("sleep 0.1"),
name: None, name: None,
display_name: String::from("sleep 0.1"), display_name: String::from("sleep 0.1"),
@@ -70,6 +71,7 @@ fn test_markup_export_auto_ms() {
parameters: BTreeMap::new(), parameters: BTreeMap::new(),
}, },
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"), command: String::from("sleep 2"),
name: None, name: None,
display_name: String::from("sleep 2"), display_name: String::from("sleep 2"),
@@ -147,6 +149,7 @@ fn test_markup_export_auto_ms() {
fn test_markup_export_auto_s() { fn test_markup_export_auto_s() {
let results = [ let results = [
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"), command: String::from("sleep 2"),
name: None, name: None,
display_name: String::from("sleep 2"), display_name: String::from("sleep 2"),
@@ -182,6 +185,7 @@ fn test_markup_export_auto_s() {
parameters: BTreeMap::new(), parameters: BTreeMap::new(),
}, },
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"), command: String::from("sleep 0.1"),
name: None, name: None,
display_name: String::from("sleep 0.1"), display_name: String::from("sleep 0.1"),
@@ -259,6 +263,7 @@ fn test_markup_export_auto_s() {
fn test_markup_export_manual_ms() { fn test_markup_export_manual_ms() {
let timing_results = [ let timing_results = [
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"), command: String::from("sleep 2"),
name: None, name: None,
display_name: String::from("sleep 2"), display_name: String::from("sleep 2"),
@@ -294,6 +299,7 @@ fn test_markup_export_manual_ms() {
parameters: BTreeMap::new(), parameters: BTreeMap::new(),
}, },
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"), command: String::from("sleep 0.1"),
name: None, name: None,
display_name: String::from("sleep 0.1"), display_name: String::from("sleep 0.1"),
@@ -370,6 +376,7 @@ fn test_markup_export_manual_ms() {
fn test_markup_export_manual_s() { fn test_markup_export_manual_s() {
let results = [ let results = [
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"), command: String::from("sleep 2"),
name: None, name: None,
display_name: String::from("sleep 2"), display_name: String::from("sleep 2"),
@@ -405,6 +412,7 @@ fn test_markup_export_manual_s() {
parameters: BTreeMap::new(), parameters: BTreeMap::new(),
}, },
BenchmarkResult { BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"), command: String::from("sleep 0.1"),
name: None, name: None,
display_name: String::from("sleep 0.1"), display_name: String::from("sleep 0.1"),
+8
View File
@@ -47,6 +47,14 @@ fn run() -> Result<()> {
{ {
for command in commands.iter() { for command in commands.iter() {
let command_line = command.get_command_line(); let command_line = command.get_command_line();
if shell_syntax::starts_with_assignment(&command_line) {
bail!(
"Cannot run '{command_line}' directly because it starts with a variable assignment.\n\
Use the --env option to set environment variables, for example:\n\n \
hyperfine --env 'OMP_NUM_THREADS=8 CFLAGS=\"-O3 -march=native\"' 'my_command'\n\n\
Alternatively, use -S / --shell=default to run the command in a shell."
);
}
if let Some(operator) = shell_syntax::first_unquoted_operator(&command_line) { if let Some(operator) = shell_syntax::first_unquoted_operator(&command_line) {
bail!( bail!(
concat!( concat!(
+49 -2
View File
@@ -1,8 +1,32 @@
//! Conservative detection of shell operators in commands run without a shell. //! Conservative detection of shell syntax in commands run without a shell.
use std::iter::Peekable; use std::iter::Peekable;
use std::str::CharIndices; use std::str::CharIndices;
/// Detects the variable assignment at the start of a command like
/// `OMP_NUM_THREADS=8 my_command`.
pub fn starts_with_assignment(command: &str) -> bool {
let Some(token) = tokenize(command).next() else {
return false;
};
let mut bytes = token.bytes();
if !bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
{
return false;
}
for byte in bytes {
if byte == b'=' {
return true;
}
if !byte.is_ascii_alphanumeric() && byte != b'_' {
return false;
}
}
false
}
/// Find the first standalone operator that is neither quoted nor escaped. /// Find the first standalone operator that is neither quoted nor escaped.
/// ///
/// Operators attached to another word (such as `hello>out`), expansions, and /// Operators attached to another word (such as `hello>out`), expansions, and
@@ -90,7 +114,30 @@ impl<'a> Iterator for Tokens<'a> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{first_unquoted_operator, tokenize}; use super::{first_unquoted_operator, starts_with_assignment, tokenize};
#[test]
fn detects_leading_assignments() {
assert!(starts_with_assignment("A=1 command"));
assert!(starts_with_assignment("A=$HOME command"));
assert!(starts_with_assignment("A= command"));
assert!(starts_with_assignment("_A2='hello world' B=2 command"));
assert!(starts_with_assignment("A=1"));
assert!(starts_with_assignment(" # comment\n A=1 command"));
assert!(!starts_with_assignment(""));
assert!(!starts_with_assignment("# A=1 command"));
assert!(!starts_with_assignment("command A=1"));
assert!(!starts_with_assignment("env A=1 command"));
assert!(!starts_with_assignment("'A=1' command"));
assert!(!starts_with_assignment("\"A\"=1 command"));
assert!(!starts_with_assignment(r"A\=1 command"));
assert!(!starts_with_assignment("./A=1 command"));
assert!(!starts_with_assignment("1A=1 command"));
assert!(!starts_with_assignment("A-B=1 command"));
assert!(!starts_with_assignment("=1 command"));
assert!(!starts_with_assignment("A"));
}
#[test] #[test]
fn tokenizer_preserves_words_quotes_and_escapes() { fn tokenizer_preserves_words_quotes_and_escapes() {
+60
View File
@@ -53,6 +53,66 @@ fn one_run_is_supported() {
.success(); .success();
} }
#[test]
fn environment_basic_usage() {
let command = if cfg!(windows) {
"echo %HYPERFINE_TEST%"
} else {
"echo \"$HYPERFINE_TEST\""
};
hyperfine()
.args(["--shell=default", "--runs=1", "--show-output"])
.arg("--command-name=env-test")
.args(["--env", r#"HYPERFINE_TEST="hello, world""#])
.arg(command)
.assert()
.success()
.stdout(predicate::str::contains("hello, world"));
}
#[test]
fn environment_parameter_expansion() {
let command = if cfg!(windows) {
"echo %HYPERFINE_TEST%"
} else {
"echo \"$HYPERFINE_TEST\""
};
hyperfine()
.args(["--shell=default", "--runs=1", "--show-output"])
.arg("--command-name=env-test")
.args(["-P", "value", "1", "2"])
.args(["--env", "HYPERFINE_TEST=hello-{value}"])
.arg(command)
.assert()
.success()
.stdout(predicate::str::contains("hello-1"))
.stdout(predicate::str::contains("hello-2"));
}
#[test]
fn rejects_invalid_environment() {
hyperfine()
.args(["--env", "OMP_NUM_THREADS"])
.arg("my_command")
.assert()
.failure()
.stderr(predicate::str::contains(
"Invalid value 'OMP_NUM_THREADS' for '--env': expected an assignment like NAME=value or NAME=\"quoted value\".",
));
}
#[test]
fn requires_explicit_shell_for_environment_assignments() {
// Without an explicit shell choice, report the leading variable assignment.
hyperfine()
.args(["--runs=1", "SOME_ENV_VAR=$HOME my_command"])
.assert()
.failure()
.stderr(predicate::str::contains(
"starts with a variable assignment",
));
}
/// Regression test: hyperfine must not panic when writing to a closed /// Regression test: hyperfine must not panic when writing to a closed
/// stdout pipe (e.g. `hyperfine ... | head -n 1` or a downstream process /// stdout pipe (e.g. `hyperfine ... | head -n 1` or a downstream process
/// that terminates early). A `BrokenPipe` error should result in a quiet /// that terminates early). A `BrokenPipe` error should result in a quiet