Add --env for benchmark environment overrides

This commit is contained in:
David Peter
2026-10-07 22:32:37 +02:00
committed by David Peter
parent 067ee70f20
commit 6dddb02d73
12 changed files with 292 additions and 15 deletions
+25
View File
@@ -155,6 +155,31 @@ Otherwise, inline the function into the benchmarked command:
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
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
possible parameter combinations.
.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
.IP
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.
pub command: String,
/// The custom name after parameter substitution, if it differs from `command`.
/// For example, `--command-name="wait {duration}s"` with `duration=1`
/// produces `Some("wait 1s")`. Without a custom name, or when the expanded
/// name equals `command`, this is `None`.
/// The custom or automatically generated name after parameter substitution,
/// if it differs from `command`. Automatic names include environment overrides.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The custom name (or `command`), followed by parameters not used in the
/// command template, in command-line order. For example, `sleep {duration}`
/// The name, followed by parameters not used in the command or environment
/// templates, in command-line order. For example, `sleep {duration}`
/// with `duration=1`, `branch=main`, and the custom name `wait {duration}s`
/// produces `wait 1s (branch = main)`. Without the custom name, this is
/// `sleep 1 (branch = main)`. Used in summaries and table exports.
@@ -40,6 +38,10 @@ pub struct BenchmarkResult {
/// Parameter values for this benchmark
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
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 {
+1
View File
@@ -215,6 +215,7 @@ impl Executor for ShellExecutor<'_> {
) -> Result<Measurement> {
let on_windows_cmd = cfg!(windows) && *self.shell == Shell::Default("cmd.exe");
let mut command_builder = self.shell.command();
command_builder.envs(command.get_environment());
command_builder.arg(if on_windows_cmd { "/C" } else { "-c" });
// 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,
iteration: executor::BenchmarkIteration,
) -> Result<Measurement> {
let command = command.clone().with_environment_from(self.command);
self.executor
.run_intermediate_command_and_measure(command, iteration, output_policy)
.run_intermediate_command_and_measure(&command, iteration, output_policy)
.map_err(|error| {
let hint = if cfg!(windows) {
"Append ' || exit /b 0' to the command if this failure can be ignored."
@@ -443,6 +444,11 @@ impl<'a> Benchmark<'a> {
Ok(BenchmarkResult {
command,
name,
environment: self
.command
.get_environment()
.map(|(name, value)| (name.to_owned(), value))
.collect(),
display_name: self.command.get_name_with_unused_parameters(),
measurements,
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, \
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::new("setup")
.long("setup")
+91 -6
View File
@@ -14,9 +14,11 @@ use crate::{
use clap::{parser::ValuesRef, ArgMatches};
use anyhow::{bail, Context, Result};
use anyhow::{bail, ensure, Context, Result};
use rust_decimal::Decimal;
type Environment = Vec<(String, String)>;
/// A command that should be benchmarked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command<'a> {
@@ -28,6 +30,9 @@ pub struct Command<'a> {
/// Zero or more parameter values.
parameters: Vec<ParameterNameAndValue<'a>>,
/// Environment overrides, before parameter substitution.
environment: Environment,
}
impl<'a> Command<'a> {
@@ -36,6 +41,7 @@ impl<'a> Command<'a> {
name,
expression,
parameters: Vec::new(),
environment: Vec::new(),
}
}
@@ -48,14 +54,26 @@ impl<'a> Command<'a> {
name,
expression,
parameters: parameters.into_iter().collect(),
environment: Vec::new(),
}
}
pub fn get_name(&self) -> String {
self.name.map_or_else(
|| self.get_command_line(),
|name| self.replace_parameters_in(name),
)
match self.name {
Some(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 {
@@ -87,6 +105,7 @@ impl<'a> Command<'a> {
if let Some(program_name) = tokens.next() {
let mut command_builder = std::process::Command::new(program_name);
command_builder.args(tokens);
command_builder.envs(self.get_environment());
Ok(command_builder)
} else {
bail!("Can not execute empty command")
@@ -97,8 +116,24 @@ impl<'a> Command<'a> {
&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 {
!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)> {
@@ -137,8 +172,58 @@ impl<'a> Command<'a> {
/// A collection of commands that should be benchmarked
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> {
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_strings = matches
.get_many::<String>("command")
+2
View File
@@ -91,6 +91,7 @@ fn test_csv() {
let results = vec![
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("echo command_a"),
name: Some(String::from("command_a")),
display_name: String::from("command_a"),
@@ -141,6 +142,7 @@ fn test_csv() {
},
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("command_b"),
name: None,
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() {
let results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
@@ -70,6 +71,7 @@ fn test_markup_export_auto_ms() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -147,6 +149,7 @@ fn test_markup_export_auto_ms() {
fn test_markup_export_auto_s() {
let results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -182,6 +185,7 @@ fn test_markup_export_auto_s() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
@@ -259,6 +263,7 @@ fn test_markup_export_auto_s() {
fn test_markup_export_manual_ms() {
let timing_results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -294,6 +299,7 @@ fn test_markup_export_manual_ms() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
@@ -370,6 +376,7 @@ fn test_markup_export_manual_ms() {
fn test_markup_export_manual_s() {
let results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -405,6 +412,7 @@ fn test_markup_export_manual_s() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
+8
View File
@@ -47,6 +47,14 @@ fn run() -> Result<()> {
{
for command in commands.iter() {
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) {
bail!(
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::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.
///
/// Operators attached to another word (such as `hello>out`), expansions, and
@@ -90,7 +114,30 @@ impl<'a> Iterator for Tokens<'a> {
#[cfg(test)]
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]
fn tokenizer_preserves_words_quotes_and_escapes() {
+60
View File
@@ -53,6 +53,66 @@ fn one_run_is_supported() {
.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
/// stdout pipe (e.g. `hyperfine ... | head -n 1` or a downstream process
/// that terminates early). A `BrokenPipe` error should result in a quiet