Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ windows-sys = { version = "0.59", features = [
once_cell = "1.19"

[target.'cfg(target_os="linux")'.dependencies]
nix = { version = "0.29", features = ["zerocopy"] }
nix = { version = "0.29", features = ["zerocopy", "signal"] }

[dependencies.clap]
version = "4"
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@

A command-line benchmarking tool.

## Personal Fork Changes

This fork includes the following modifications for more accurate low-level benchmarking:

### CPU Pinning (`--cpu`)
Pin the benchmarked command to a specific CPU core using `sched_setaffinity`. This reduces noise from CPU migration during benchmarks.
```sh
hyperfine --cpu 0 --shell=none './my_program'
```

### Scheduling Priority (`--priority`)
Set the scheduling policy of the benchmarked command to `realtime` (SCHED_FIFO) or `idle` (SCHED_IDLE) for reduced scheduler interference.
```sh
hyperfine --priority realtime --shell=none './my_program'
```
Note: Realtime priority requires `CAP_SYS_NICE` capability or root. Grant it with:
```sh
sudo setcap cap_sys_nice+ep $(which hyperfine)
```

### Post-Fork Timing (Linux)
Timing now starts after `fork()` but before `exec()`, excluding fork overhead from measurements. This provides more accurate timing for fast-starting programs.

### Per-Child Memory Measurement (Linux)
Uses `wait4()` syscall to get per-child `rusage` instead of cumulative `getrusage(RUSAGE_CHILDREN)`, fixing memory reporting accuracy when running multiple benchmarks.

---

**Demo**: Benchmarking [`fd`](https://github.com/sharkdp/fd) and
[`find`](https://www.gnu.org/software/findutils/):

Expand Down
10 changes: 9 additions & 1 deletion src/benchmark/benchmark_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,18 @@ pub struct BenchmarkResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub times: Option<Vec<Second>>,

/// Maximum memory usage of the process, in bytes
/// Memory usage measurements of the process, in bytes (one per run)
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_usage_byte: Option<Vec<u64>>,

/// Minimum memory usage across all runs, in bytes
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_min: Option<u64>,

/// Peak memory usage (max across all runs), in bytes
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_max: Option<u64>,

/// Exit codes of all command invocations
pub exit_codes: Vec<Option<i32>>,

Expand Down
95 changes: 91 additions & 4 deletions src/benchmark/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
use std::os::windows::process::CommandExt;
use std::process::ExitStatus;

#[cfg(target_os = "linux")]
use std::os::unix::process::CommandExt;

use crate::command::Command;
use crate::options::{
CmdFailureAction, CommandInputPolicy, CommandOutputPolicy, Options, OutputStyleOption, Shell,
CmdFailureAction, CommandInputPolicy, CommandOutputPolicy, Options, OutputStyleOption,
SchedulingPolicy, Shell,
};
use crate::output::progress_bar::get_progress_bar;
use crate::timer::{execute_and_measure, TimerResult};
Expand All @@ -16,6 +20,54 @@ use super::timing_result::TimingResult;
use anyhow::{bail, Context, Result};
use statistical::mean;

/// Set CPU affinity and scheduling policy in the child process before exec.
/// This runs after fork() but before exec().
#[cfg(target_os = "linux")]
fn setup_process_priority(
cpu_affinity: Option<usize>,
scheduling_priority: Option<SchedulingPolicy>,
) -> impl FnMut() -> std::io::Result<()> {
move || {
if let Some(cpu) = cpu_affinity {
unsafe {
let mut set: libc::cpu_set_t = std::mem::zeroed();
libc::CPU_SET(cpu, &mut set);
if libc::sched_setaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &set) != 0 {
return Err(std::io::Error::last_os_error());
}
}
}

if let Some(policy) = scheduling_priority {
unsafe {
let (sched_policy, priority) = match policy {
SchedulingPolicy::Realtime => (libc::SCHED_FIFO, 99),
SchedulingPolicy::Idle => (libc::SCHED_IDLE, 0),
};
let param = libc::sched_param {
sched_priority: priority,
};
if libc::sched_setscheduler(0, sched_policy, &param) != 0 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EPERM) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"Permission denied setting {:?} scheduler. \
Try: sudo setcap cap_sys_nice+ep $(which hyperfine)",
policy
),
));
}
return Err(err);
}
}
}

Ok(())
}
}

pub enum BenchmarkIteration {
NonBenchmarkRun,
Warmup(u64),
Expand All @@ -30,6 +82,14 @@ impl BenchmarkIteration {
BenchmarkIteration::Benchmark(i) => Some(format!("{}", i)),
}
}

/// Returns `true` if the benchmark iteration is [`NonBenchmarkRun`].
///
/// [`NonBenchmarkRun`]: BenchmarkIteration::NonBenchmarkRun
#[must_use]
pub fn is_non_benchmark_run(&self) -> bool {
matches!(self, Self::NonBenchmarkRun)
}
}

pub trait Executor {
Expand Down Expand Up @@ -62,9 +122,17 @@ fn run_command_and_measure_common(
command_input_policy: &CommandInputPolicy,
command_output_policy: &CommandOutputPolicy,
command_name: &str,
cpu_affinity: Option<usize>,
scheduling_priority: Option<SchedulingPolicy>,
) -> Result<TimerResult> {
let stdin = command_input_policy.get_stdin()?;
let (stdout, stderr) = command_output_policy.get_stdout_stderr()?;
let until_text = if iteration.is_non_benchmark_run() {
None
} else {
command_output_policy.get_until_text()
};

command.stdin(stdin).stdout(stdout).stderr(stderr);

command.env(
Expand All @@ -76,7 +144,15 @@ fn run_command_and_measure_common(
command.env("HYPERFINE_ITERATION", value);
}

let result = execute_and_measure(command)
// Set CPU affinity and scheduling policy in child process before exec
#[cfg(target_os = "linux")]
if cpu_affinity.is_some() || scheduling_priority.is_some() {
unsafe {
command.pre_exec(setup_process_priority(cpu_affinity, scheduling_priority));
}
}

let result = execute_and_measure(command, until_text)
.with_context(|| format!("Failed to run command '{command_name}'"))?;

if !result.status.success() {
Expand Down Expand Up @@ -144,11 +220,14 @@ impl Executor for RawExecutor<'_> {
&self.options.command_input_policy,
output_policy,
&command.get_command_line(),
self.options.cpu_affinity,
self.options.scheduling_priority,
)?;

Ok((
TimingResult {
time_real: result.time_real,
time_real_full: result.time_real_full,
time_user: result.time_user,
time_system: result.time_system,
memory_usage_byte: result.memory_usage_byte,
Expand Down Expand Up @@ -209,18 +288,22 @@ impl Executor for ShellExecutor<'_> {
&self.options.command_input_policy,
output_policy,
&command.get_command_line(),
self.options.cpu_affinity,
self.options.scheduling_priority,
)?;

// Subtract shell spawning time
if let Some(spawning_time) = self.shell_spawning_time {
result.time_real = (result.time_real - spawning_time.time_real).max(0.0);
result.time_real_full = (result.time_real_full - spawning_time.time_real_full).max(0.0);
result.time_user = (result.time_user - spawning_time.time_user).max(0.0);
result.time_system = (result.time_system - spawning_time.time_system).max(0.0);
}

Ok((
TimingResult {
time_real: result.time_real,
time_real_full: result.time_real_full,
time_user: result.time_user,
time_system: result.time_system,
memory_usage_byte: result.memory_usage_byte,
Expand Down Expand Up @@ -284,8 +367,10 @@ impl Executor for ShellExecutor<'_> {
bar.finish_and_clear()
}

let mean_time_real = mean(&times_real);
self.shell_spawning_time = Some(TimingResult {
time_real: mean(&times_real),
time_real: mean_time_real,
time_real_full: mean_time_real,
time_user: mean(&times_user),
time_system: mean(&times_system),
memory_usage_byte: 0,
Expand Down Expand Up @@ -339,9 +424,11 @@ impl Executor for MockExecutor {
ExitStatus::from_raw(0)
};

let time_real = Self::extract_time(command.get_command_line());
Ok((
TimingResult {
time_real: Self::extract_time(command.get_command_line()),
time_real,
time_real_full: time_real,
time_user: 0.0,
time_system: 0.0,
memory_usage_byte: 0,
Expand Down
70 changes: 67 additions & 3 deletions src/benchmark/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ use crate::options::{
CmdFailureAction, CommandOutputPolicy, ExecutorKind, Options, OutputStyleOption,
};
use crate::outlier_detection::{modified_zscores, OUTLIER_THRESHOLD};
use crate::output::format::{format_duration, format_duration_unit};
use crate::output::format::{format_duration, format_duration_unit, format_memory_value};
use crate::output::progress_bar::get_progress_bar;
use crate::output::warnings::{OutlierWarningOptions, Warnings};
use crate::output::warnings::{OffCpuSeverity, OutlierWarningOptions, Warnings};
use crate::parameter::ParameterNameAndValue;
use crate::util::exit_code::extract_exit_code;
use crate::util::min_max::{max, min};
Expand All @@ -31,6 +31,15 @@ use self::executor::Executor;
/// Threshold for warning about fast execution time
pub const MIN_EXECUTION_TIME: Second = 5e-3;

/// Minimum wall time (in seconds) before off-CPU warnings are considered.
/// Below this, timing resolution makes ratio calculations unreliable.
const OFF_CPU_WARNING_MIN_WALL_TIME: Second = 0.005; // 5ms

/// Ratio thresholds for off-CPU time warnings (wall time / CPU time)
const OFF_CPU_RATIO_NOTE: f64 = 2.0; // 2× triggers a note
const OFF_CPU_RATIO_WARNING: f64 = 3.0; // 3× triggers a warning
const OFF_CPU_RATIO_SEVERE: f64 = 5.0; // 5× triggers severe warning

pub struct Benchmark<'a> {
number: usize,
command: &'a Command<'a>,
Expand Down Expand Up @@ -257,8 +266,9 @@ impl<'a> Benchmark<'a> {
conclusion_result.map_or(0.0, |res| res.time_real + self.executor.time_overhead());

// Determine number of benchmark runs
// Use time_real_full (includes fork overhead) for scheduling purposes
let runs_in_min_time = (self.options.min_benchmarking_time
/ (res.time_real
/ (res.time_real_full
+ self.executor.time_overhead()
+ preparation_overhead
+ conclusion_overhead)) as u64;
Expand Down Expand Up @@ -348,6 +358,15 @@ impl<'a> Benchmark<'a> {
let user_mean = mean(&times_user);
let system_mean = mean(&times_system);

// Compute memory usage statistics (filter out zero values for platforms that don't support it)
let memory_values: Vec<u64> = memory_usage_byte
.iter()
.copied()
.filter(|&m| m > 0)
.collect();
let memory_min = memory_values.iter().copied().min();
let memory_max = memory_values.iter().copied().max();

// Formatting and console output
let (mean_str, time_unit) = format_duration_unit(t_mean, self.options.time_unit);
let min_str = format_duration(t_min, Some(time_unit));
Expand Down Expand Up @@ -389,6 +408,25 @@ impl<'a> Benchmark<'a> {
num_str.dimmed()
);
}

// Display memory usage if available (> 0)
if let (Some(mem_min), Some(mem_max)) = (memory_min, memory_max) {
if mem_min == mem_max {
// All runs used the same amount of memory
println!(
" Memory: {}",
format_memory_value(mem_max, Some(8)).yellow()
);
} else {
println!(
" Memory ({} … {}): {} … {}",
"min".cyan(),
"max".purple(),
format_memory_value(mem_min, Some(8)).cyan(),
format_memory_value(mem_max, Some(8)).purple()
);
}
}
}

// Warnings
Expand Down Expand Up @@ -429,6 +467,30 @@ impl<'a> Benchmark<'a> {
warnings.push(Warnings::OutliersDetected(outlier_warning_options));
}

// Check for significant off-CPU time (wall time >> user + system time)
let cpu_time = user_mean + system_mean;
if t_mean >= OFF_CPU_WARNING_MIN_WALL_TIME && cpu_time > 0.0 {
let ratio = t_mean / cpu_time;
let severity = if ratio >= OFF_CPU_RATIO_SEVERE {
Some(OffCpuSeverity::Severe)
} else if ratio >= OFF_CPU_RATIO_WARNING {
Some(OffCpuSeverity::Warning)
} else if ratio >= OFF_CPU_RATIO_NOTE {
Some(OffCpuSeverity::Note)
} else {
None
};

if let Some(severity) = severity {
warnings.push(Warnings::OffCpuTimeDetected {
wall_time: t_mean,
cpu_time,
ratio,
severity,
});
}
}

if !warnings.is_empty() {
eprintln!(" ");

Expand All @@ -455,6 +517,8 @@ impl<'a> Benchmark<'a> {
max: t_max,
times: Some(times_real),
memory_usage_byte: Some(memory_usage_byte),
memory_min,
memory_max,
exit_codes,
parameters: self
.command
Expand Down
2 changes: 2 additions & 0 deletions src/benchmark/relative_speed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ fn create_result(name: &str, mean: Scalar) -> BenchmarkResult {
max: mean,
times: None,
memory_usage_byte: None,
memory_min: None,
memory_max: None,
exit_codes: Vec::new(),
parameters: BTreeMap::new(),
}
Expand Down
5 changes: 4 additions & 1 deletion src/benchmark/timing_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ use crate::util::units::Second;
/// Results from timing a single command
#[derive(Debug, Default, Copy, Clone)]
pub struct TimingResult {
/// Wall clock time
/// Wall clock time (post-exec on Linux, for reporting)
pub time_real: Second,

/// Full wall clock time including fork overhead (for scheduling)
pub time_real_full: Second,

/// Time spent in user mode
pub time_user: Second,

Expand Down
Loading
Loading