From 4262c75b10287cfea7786391950813ddee97d74e Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Sun, 18 Jan 2026 20:34:27 +0100 Subject: [PATCH] feat(exec-harness): make the config less strict about its config --- .../src/walltime/benchmark_loop.rs | 263 ++++++++++++++++++ crates/exec-harness/src/walltime/config.rs | 98 +++---- crates/exec-harness/src/walltime/mod.rs | 159 +---------- crates/exec-harness/src/walltime/tests.rs | 1 + 4 files changed, 311 insertions(+), 210 deletions(-) create mode 100644 crates/exec-harness/src/walltime/benchmark_loop.rs diff --git a/crates/exec-harness/src/walltime/benchmark_loop.rs b/crates/exec-harness/src/walltime/benchmark_loop.rs new file mode 100644 index 00000000..02c75639 --- /dev/null +++ b/crates/exec-harness/src/walltime/benchmark_loop.rs @@ -0,0 +1,263 @@ +use super::ExecutionOptions; +use super::config::RoundOrTime; +use crate::prelude::*; +use codspeed::instrument_hooks::InstrumentHooks; +use std::process::Command; +use std::time::Duration; + +pub fn run_rounds( + bench_uri: String, + command: Vec, + config: &ExecutionOptions, +) -> Result> { + let warmup_time_ns = config.warmup_time_ns; + let hooks = InstrumentHooks::instance(); + + let do_one_round = |times_per_round_ns: &mut Vec| { + let mut child = Command::new(&command[0]) + .args(&command[1..]) + .spawn() + .context("Failed to execute command")?; + let bench_round_start_ts_ns = InstrumentHooks::current_timestamp(); + let status = child + .wait() + .context("Failed to wait for command to finish")?; + + let bench_round_end_ts_ns = InstrumentHooks::current_timestamp(); + hooks.add_benchmark_timestamps(bench_round_start_ts_ns, bench_round_end_ts_ns); + + if !status.success() { + bail!("Command exited with non-zero status: {status}"); + } + + times_per_round_ns.push((bench_round_end_ts_ns - bench_round_start_ts_ns) as u128); + + Ok(()) + }; + + // Compute the number of rounds to perform (potentially undefined if no warmup and only time constraints) + let rounds_to_perform: Option = if warmup_time_ns > 0 { + match compute_rounds_from_warmup(config, hooks, &bench_uri, do_one_round)? { + WarmupResult::EarlyReturn(times) => return Ok(times), + WarmupResult::Rounds(rounds) => Some(rounds), + } + } else { + extract_rounds_from_config(config) + }; + + let (min_time_ns, max_time_ns) = extract_time_constraints(config); + + // Validate that we have at least one constraint when warmup is disabled + if warmup_time_ns == 0 + && rounds_to_perform.is_none() + && min_time_ns.is_none() + && max_time_ns.is_none() + { + bail!( + "When warmup is disabled, at least one constraint (min_rounds, max_rounds, min_time, or max_time) must be specified" + ); + } + + if let Some(rounds) = rounds_to_perform { + info!("Warmup done, now performing {rounds} rounds"); + } else { + debug!( + "Running in degraded mode (no warmup, time-based constraints only): min_time={}, max_time={}", + min_time_ns + .map(format_ns) + .unwrap_or_else(|| "none".to_string()), + max_time_ns + .map(format_ns) + .unwrap_or_else(|| "none".to_string()) + ); + } + + let mut times_per_round_ns = rounds_to_perform + .map(|r| Vec::with_capacity(r as usize)) + .unwrap_or_default(); + let mut current_round: u64 = 0; + + hooks.start_benchmark().unwrap(); + + debug!( + "Starting loop with ending conditions: \ + rounds {rounds_to_perform:?}, \ + min_time_ns {min_time_ns:?}, \ + max_time_ns {max_time_ns:?}" + ); + let round_start_ts_ns = InstrumentHooks::current_timestamp(); + loop { + do_one_round(&mut times_per_round_ns)?; + current_round += 1; + + let elapsed_ns = InstrumentHooks::current_timestamp() - round_start_ts_ns; + + // Check stop conditions + let reached_max_rounds = rounds_to_perform.is_some_and(|r| current_round >= r); + let reached_max_time = max_time_ns.is_some_and(|t| elapsed_ns >= t); + let reached_min_time = min_time_ns.is_some_and(|t| elapsed_ns >= t); + + // Stop if we hit max_time + if reached_max_time { + debug!( + "Reached maximum time limit after {current_round} rounds (elapsed: {}, max: {})", + format_ns(elapsed_ns), + format_ns(max_time_ns.unwrap()) + ); + break; + } + + // Stop if we hit max_rounds + if reached_max_rounds { + break; + } + + // If no rounds constraint, stop when min_time is reached + if rounds_to_perform.is_none() && reached_min_time { + debug!( + "Reached minimum time after {current_round} rounds (elapsed: {}, min: {})", + format_ns(elapsed_ns), + format_ns(min_time_ns.unwrap()) + ); + break; + } + } + hooks.stop_benchmark().unwrap(); + hooks.set_executed_benchmark(&bench_uri).unwrap(); + + Ok(times_per_round_ns) +} + +enum WarmupResult { + /// Warmup satisfied max_time constraint, return early with these times + EarlyReturn(Vec), + /// Continue with this many rounds + Rounds(u64), +} + +/// Run warmup rounds and compute the number of benchmark rounds to perform +fn compute_rounds_from_warmup( + config: &ExecutionOptions, + hooks: &InstrumentHooks, + bench_uri: &str, + do_one_round: F, +) -> Result +where + F: Fn(&mut Vec) -> Result<()>, +{ + let mut warmup_times_ns = Vec::new(); + let warmup_start_ts_ns = InstrumentHooks::current_timestamp(); + + hooks.start_benchmark().unwrap(); + while InstrumentHooks::current_timestamp() < warmup_start_ts_ns + config.warmup_time_ns { + do_one_round(&mut warmup_times_ns)?; + } + hooks.stop_benchmark().unwrap(); + let warmup_end_ts_ns = InstrumentHooks::current_timestamp(); + + // Check if single warmup round already exceeded max_time + if let [single_warmup_round_duration_ns] = warmup_times_ns.as_slice() { + match config.max { + Some(RoundOrTime::TimeNs(time_ns)) | Some(RoundOrTime::Both { time_ns, .. }) => { + if time_ns <= *single_warmup_round_duration_ns as u64 { + info!( + "Warmup duration ({}) exceeded or met max_time ({}). No more rounds will be performed.", + format_ns(*single_warmup_round_duration_ns as u64), + format_ns(time_ns) + ); + hooks.set_executed_benchmark(bench_uri).unwrap(); + return Ok(WarmupResult::EarlyReturn(warmup_times_ns)); + } + } + _ => { /* No max time constraint */ } + } + } + + info!("Completed {} warmup rounds", warmup_times_ns.len()); + + let average_time_per_round_ns = + (warmup_end_ts_ns - warmup_start_ts_ns) / warmup_times_ns.len() as u64; + + let actual_min_rounds = compute_min_rounds(config, average_time_per_round_ns); + let actual_max_rounds = compute_max_rounds(config, average_time_per_round_ns); + + let rounds = match (actual_min_rounds, actual_max_rounds) { + (Some(min), Some(max)) if min > max => { + warn!( + "Computed min rounds ({min}) is greater than max rounds ({max}). Using max rounds.", + ); + max + } + (Some(min), Some(max)) => (min + max) / 2, + (None, Some(max)) => max, + (Some(min), None) => min, + (None, None) => { + bail!("Unable to determine number of rounds to perform"); + } + }; + + Ok(WarmupResult::Rounds(rounds)) +} + +/// Compute the minimum number of rounds based on config and average round time +fn compute_min_rounds(config: &ExecutionOptions, avg_time_per_round_ns: u64) -> Option { + match &config.min { + Some(RoundOrTime::Rounds(rounds)) => Some(*rounds), + Some(RoundOrTime::TimeNs(time_ns)) => { + Some(((time_ns + avg_time_per_round_ns) / avg_time_per_round_ns) + 1) + } + Some(RoundOrTime::Both { rounds, time_ns }) => { + let rounds_from_time = ((time_ns + avg_time_per_round_ns) / avg_time_per_round_ns) + 1; + Some((*rounds).max(rounds_from_time)) + } + None => None, + } +} + +/// Compute the maximum number of rounds based on config and average round time +fn compute_max_rounds(config: &ExecutionOptions, avg_time_per_round_ns: u64) -> Option { + match &config.max { + Some(RoundOrTime::Rounds(rounds)) => Some(*rounds), + Some(RoundOrTime::TimeNs(time_ns)) => { + Some((time_ns + avg_time_per_round_ns) / avg_time_per_round_ns) + } + Some(RoundOrTime::Both { rounds, time_ns }) => { + let rounds_from_time = (time_ns + avg_time_per_round_ns) / avg_time_per_round_ns; + Some((*rounds).min(rounds_from_time)) + } + None => None, + } +} + +/// Extract rounds directly from config (used when warmup is disabled) +fn extract_rounds_from_config(config: &ExecutionOptions) -> Option { + match (&config.max, &config.min) { + (Some(RoundOrTime::Rounds(rounds)), _) | (_, Some(RoundOrTime::Rounds(rounds))) => { + Some(*rounds) + } + (Some(RoundOrTime::Both { rounds, .. }), _) + | (_, Some(RoundOrTime::Both { rounds, .. })) => Some(*rounds), + _ => None, + } +} + +/// Extract time constraints from config for stop conditions +fn extract_time_constraints(config: &ExecutionOptions) -> (Option, Option) { + let min_time_ns = match &config.min { + Some(RoundOrTime::TimeNs(time_ns)) | Some(RoundOrTime::Both { time_ns, .. }) => { + Some(*time_ns) + } + _ => None, + }; + let max_time_ns = match &config.max { + Some(RoundOrTime::TimeNs(time_ns)) | Some(RoundOrTime::Both { time_ns, .. }) => { + Some(*time_ns) + } + _ => None, + }; + (min_time_ns, max_time_ns) +} + +fn format_ns(ns: u64) -> String { + format!("{:?}", Duration::from_nanos(ns)) +} diff --git a/crates/exec-harness/src/walltime/config.rs b/crates/exec-harness/src/walltime/config.rs index ccb87001..00f562a4 100644 --- a/crates/exec-harness/src/walltime/config.rs +++ b/crates/exec-harness/src/walltime/config.rs @@ -40,39 +40,39 @@ pub struct WalltimeExecutionArgs { /// Maximum total time to spend running benchmarks (includes warmup). /// Execution stops when this time is reached, even if min_rounds is not satisfied. - /// Cannot be used together with --min-rounds. + /// When used with --min-rounds, the max_time constraint takes priority. /// /// Format: duration string (e.g., "3s", "15s", "1m") or number in seconds (e.g., "3", "15") /// Default: 3s if no other constraints are set, 0 (unlimited) if one of min_time, max_rounds, /// or min_rounds is set - #[arg(long, value_name = "DURATION", conflicts_with = "min_rounds")] + #[arg(long, value_name = "DURATION")] pub max_time: Option, /// Minimum total time to spend running benchmarks (excludes warmup). /// Ensures benchmarks run for at least this duration for statistical accuracy. - /// Cannot be used together with --max-rounds. + /// When used with --max-rounds, we try to satisfy both if possible, else max_rounds takes priority. /// /// Format: duration string (e.g., "1s", "500ms") or number in seconds (e.g., "1", "0.5") /// Default: undefined (no minimum) - #[arg(long, value_name = "DURATION", conflicts_with = "max_rounds")] + #[arg(long, value_name = "DURATION")] pub min_time: Option, /// Maximum number of benchmark iterations (rounds) to perform. /// Execution stops after this many rounds, even if max_time is not reached. - /// Cannot be used together with --min-time. + /// When used with --min-time, we try to satisfy both if possible, else max_rounds takes priority. /// /// Format: positive integer /// Default: undefined (determined by timing constraints) - #[arg(long, value_name = "COUNT", conflicts_with = "min_time")] + #[arg(long, value_name = "COUNT")] pub max_rounds: Option, /// Minimum number of benchmark iterations (rounds) to perform. /// Ensures at least this many rounds are executed for statistical accuracy. - /// Cannot be used together with --max-time. + /// When used with --max-time, the max_time constraint takes priority. /// /// Format: positive integer /// Default: undefined (determined by timing constraints) - #[arg(long, value_name = "COUNT", conflicts_with = "max_time")] + #[arg(long, value_name = "COUNT")] pub min_rounds: Option, } @@ -136,10 +136,16 @@ impl TryFrom for ExecutionOptions { /// Convert WalltimeExecutionArgs to ExecutionOptions with validation /// - /// Check that the input is coherent with rules - /// - 1: Cannot mix time-based and round-based constraints for the opposite bounds - /// - 2: min_xxx cannot be greater than max_xxx - /// - 3: If warmup is disabled, must specify at least one rounds bound explicitly + /// Check that the input is coherent with rules: + /// - min_xxx cannot be greater than max_xxx (for same dimension) + /// + /// When constraints of different dimensions are mixed (e.g., min_time + max_rounds): + /// - We try to satisfy both if possible + /// - Otherwise, the MAX constraint takes priority + /// + /// When warmup is disabled and only time constraints are provided: + /// - We run in "degraded mode" where we try to satisfy the constraint best-effort + /// - For max_time: actual_benched_time < max_time + one_iteration_time fn try_from(args: WalltimeExecutionArgs) -> Result { // Parse duration strings let warmup_time_ns = args @@ -172,16 +178,7 @@ impl TryFrom for ExecutionOptions { .transpose() .context("Invalid min_time")?; - // Rule 1: Cannot mix time-based and round-based constraints for the opposite bounds - if min_time_ns.is_some() && args.max_rounds.is_some() { - bail!("Cannot use both min_time and max_rounds. Choose one minimum constraint."); - } - - if max_time_ns > 0 && args.min_rounds.is_some() { - bail!("Cannot use both max_time and min_rounds. Choose one maximum constraint."); - } - - // Rule 2: min_xxx cannot be greater than max_xxx + // Validation: min_xxx cannot be greater than max_xxx (for same dimension) if max_time_ns > 0 { if let Some(min) = min_time_ns { if min > max_time_ns { @@ -200,15 +197,8 @@ impl TryFrom for ExecutionOptions { } } - // Rule 3: If warmup is disabled, must specify at least one rounds bound explicitly - if warmup_time_ns == Some(0) && args.min_rounds.is_none() && args.max_rounds.is_none() { - bail!( - "When warmup_time is 0, you must specify either min_rounds or max_rounds. \ - Without warmup, the number of iterations cannot be determined automatically." - ); - } - // Build min/max using RoundOrTime enum + // Now we allow mixing time and rounds constraints across min/max bounds let min = match (args.min_rounds, min_time_ns) { (Some(rounds), None) => Some(RoundOrTime::Rounds(rounds)), (None, Some(time_ns)) => Some(RoundOrTime::TimeNs(time_ns)), @@ -341,26 +331,28 @@ mod tests { // Business rule validation tests #[test] - fn test_validation_cannot_mix_min_time_and_max_rounds() { + fn test_mixing_min_time_and_max_rounds_is_allowed() { + // min_time + max_rounds (different dimensions) + // The constraint will be: try to satisfy both if possible, else max_rounds takes priority let result: Result = WalltimeExecutionArgs { warmup_time: Some("1s".to_string()), - max_time: Some("10s".to_string()), + max_time: None, min_time: Some("2s".to_string()), - max_rounds: None, - min_rounds: Some(5), + max_rounds: Some(10), + min_rounds: None, } .try_into(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("Cannot use both max_time and min_rounds"), - "Expected error about mixing min_time and min_rounds, got: {err}" - ); + assert!(result.is_ok()); + let opts = result.unwrap(); + assert!(matches!(opts.min, Some(RoundOrTime::TimeNs(_)))); + assert!(matches!(opts.max, Some(RoundOrTime::Rounds(10)))); } #[test] - fn test_validation_cannot_mix_max_time_and_min_rounds() { + fn test_mixing_max_time_and_min_rounds_is_allowed() { + // Now allowed: max_time + min_rounds (different dimensions) + // The constraint will be: max_time constraint takes priority let result: Result = WalltimeExecutionArgs { warmup_time: Some("1s".to_string()), max_time: Some("10s".to_string()), @@ -370,12 +362,10 @@ mod tests { } .try_into(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("Cannot use both max_time and min_rounds"), - "Expected error about mixing max_time and max_rounds, got: {err}" - ); + assert!(result.is_ok()); + let opts = result.unwrap(); + assert!(matches!(opts.min, Some(RoundOrTime::Rounds(5)))); + assert!(matches!(opts.max, Some(RoundOrTime::TimeNs(_)))); } #[test] @@ -417,7 +407,9 @@ mod tests { } #[test] - fn test_validation_no_warmup_requires_rounds() { + fn test_no_warmup_with_time_only_is_allowed() { + // No warmup + time constraints only is now allowed (degraded mode) + // Validation happens at runtime in run_rounds let result: Result = WalltimeExecutionArgs { warmup_time: Some("0".to_string()), // No warmup max_time: Some("10s".to_string()), @@ -427,12 +419,10 @@ mod tests { } .try_into(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("warmup_time is 0") && err.contains("min_rounds or max_rounds"), - "Expected error about needing rounds when warmup is 0, got: {err}" - ); + assert!(result.is_ok()); + let opts = result.unwrap(); + assert_eq!(opts.warmup_time_ns, 0); + assert!(matches!(opts.max, Some(RoundOrTime::TimeNs(_)))); } #[test] diff --git a/crates/exec-harness/src/walltime/mod.rs b/crates/exec-harness/src/walltime/mod.rs index 7fadc1f9..62da5b4a 100644 --- a/crates/exec-harness/src/walltime/mod.rs +++ b/crates/exec-harness/src/walltime/mod.rs @@ -1,15 +1,13 @@ +mod benchmark_loop; mod config; pub use config::ExecutionOptions; -use config::RoundOrTime; pub use config::WalltimeExecutionArgs; use runner_shared::walltime_results::WalltimeBenchmark; pub use runner_shared::walltime_results::WalltimeResults; use crate::prelude::*; use crate::uri::NameAndUri; -use codspeed::instrument_hooks::InstrumentHooks; -use std::process::Command; pub fn perform( name_and_uri: NameAndUri, @@ -21,7 +19,8 @@ pub fn perform( uri: bench_uri, } = name_and_uri; - let times_per_round_ns = run_rounds(bench_uri.clone(), command, execution_options)?; + let times_per_round_ns = + benchmark_loop::run_rounds(bench_uri.clone(), command, execution_options)?; // Collect walltime results let max_time_ns = times_per_round_ns.iter().copied().max(); @@ -48,157 +47,5 @@ pub fn perform( Ok(()) } -fn run_rounds( - bench_uri: String, - command: Vec, - config: &ExecutionOptions, -) -> Result> { - let warmup_time_ns = config.warmup_time_ns; - let hooks = InstrumentHooks::instance(); - - let do_one_round = |times_per_round_ns: &mut Vec| { - let mut child = Command::new(&command[0]) - .args(&command[1..]) - .spawn() - .context("Failed to execute command")?; - let bench_round_start_ts_ns = InstrumentHooks::current_timestamp(); - let status = child - .wait() - .context("Failed to wait for command to finish")?; - - let bench_round_end_ts_ns = InstrumentHooks::current_timestamp(); - hooks.add_benchmark_timestamps(bench_round_start_ts_ns, bench_round_end_ts_ns); - - if !status.success() { - bail!("Command exited with non-zero status: {status}"); - } - - times_per_round_ns.push((bench_round_end_ts_ns - bench_round_start_ts_ns) as u128); - - Ok(()) - }; - - // Compute the number of rounds to perform, either from warmup or directly from config - let rounds_to_perform = if warmup_time_ns > 0 { - let mut warmup_times_ns = Vec::new(); - let warmup_start_ts_ns = InstrumentHooks::current_timestamp(); - - hooks.start_benchmark().unwrap(); - while InstrumentHooks::current_timestamp() < warmup_start_ts_ns + warmup_time_ns { - do_one_round(&mut warmup_times_ns)?; - } - hooks.stop_benchmark().unwrap(); - let warmup_end_ts_ns = InstrumentHooks::current_timestamp(); - - if let [single_warmup_round_duration_ns] = warmup_times_ns.as_slice() { - match config.max { - Some(RoundOrTime::TimeNs(time_ns)) | Some(RoundOrTime::Both { time_ns, .. }) => { - if time_ns <= *single_warmup_round_duration_ns as u64 { - info!( - "Warmup duration ({single_warmup_round_duration_ns} ns) exceeded or met max_time ({time_ns} ns). No more rounds will be performed." - ); - // Mark benchmark as executed for the runner to register - hooks.set_executed_benchmark(&bench_uri).unwrap(); - return Ok(warmup_times_ns); - } - } - _ => { /* No max time constraint */ } - } - } - - info!("Completed {} warmup rounds", warmup_times_ns.len()); - - let average_time_per_round_ns = - (warmup_end_ts_ns - warmup_start_ts_ns) / warmup_times_ns.len() as u64; - - // Extract min rounds from config - let actual_min_rounds = match &config.min { - Some(RoundOrTime::Rounds(rounds)) => Some(*rounds), - Some(RoundOrTime::TimeNs(time_ns)) => { - Some(((time_ns + average_time_per_round_ns) / average_time_per_round_ns) + 1) - } - Some(RoundOrTime::Both { rounds, time_ns }) => { - let rounds_from_time = - ((time_ns + average_time_per_round_ns) / average_time_per_round_ns) + 1; - Some((*rounds).max(rounds_from_time)) - } - None => None, - }; - - // Extract max rounds from config - let actual_max_rounds = match &config.max { - Some(RoundOrTime::Rounds(rounds)) => Some(*rounds), - Some(RoundOrTime::TimeNs(time_ns)) => { - Some((time_ns + average_time_per_round_ns) / average_time_per_round_ns) - } - Some(RoundOrTime::Both { rounds, time_ns }) => { - let rounds_from_time = - (time_ns + average_time_per_round_ns) / average_time_per_round_ns; - Some((*rounds).min(rounds_from_time)) - } - None => None, - }; - - match (actual_min_rounds, actual_max_rounds) { - (Some(min), Some(max)) if min > max => { - warn!( - "Computed min rounds ({min}) is greater than max rounds ({max}). Using max rounds.", - ); - max - } - (Some(min), Some(max)) => (min + max) / 2, - (None, Some(max)) => max, - (Some(min), None) => min, - (None, None) => { - bail!("Unable to determine number of rounds to perform"); - } - } - } else { - // No warmup, extract rounds directly from config - match (&config.max, &config.min) { - (Some(RoundOrTime::Rounds(rounds)), _) | (_, Some(RoundOrTime::Rounds(rounds))) => { - *rounds - } - (Some(RoundOrTime::Both { rounds, .. }), _) - | (_, Some(RoundOrTime::Both { rounds, .. })) => *rounds, - _ => bail!("Either max_rounds or min_rounds must be specified when warmup is disabled"), - } - }; - - info!("Performing {rounds_to_perform} rounds"); - - let round_start_ts_ns = InstrumentHooks::current_timestamp(); - let mut times_per_round_ns = Vec::with_capacity(rounds_to_perform.try_into().unwrap()); - - hooks.start_benchmark().unwrap(); - for round in 0..rounds_to_perform { - do_one_round(&mut times_per_round_ns)?; - - // Check if we've exceeded max time - let max_time_ns = match &config.max { - Some(RoundOrTime::TimeNs(time_ns)) | Some(RoundOrTime::Both { time_ns, .. }) => { - Some(*time_ns) - } - _ => None, - }; - - if let Some(max_time_ns) = max_time_ns { - let current_round = round + 1; - if current_round < rounds_to_perform - && InstrumentHooks::current_timestamp() - round_start_ts_ns > max_time_ns - { - info!( - "Prematurally reached maximum time limit after {current_round}/{rounds_to_perform} rounds, stopping here" - ); - break; - } - } - } - hooks.stop_benchmark().unwrap(); - hooks.set_executed_benchmark(&bench_uri).unwrap(); - - Ok(times_per_round_ns) -} - #[cfg(test)] mod tests; diff --git a/crates/exec-harness/src/walltime/tests.rs b/crates/exec-harness/src/walltime/tests.rs index bec40a75..c85feba8 100644 --- a/crates/exec-harness/src/walltime/tests.rs +++ b/crates/exec-harness/src/walltime/tests.rs @@ -1,6 +1,7 @@ use anyhow::Result; use tempfile::TempDir; +use super::benchmark_loop::run_rounds; use super::*; // Helper to create a simple sleep 100ms command