diff --git a/Cargo.lock b/Cargo.lock index 070f642ae..f1c33ab03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,6 +265,7 @@ dependencies = [ "csv", "indicatif", "libc", + "nix", "predicates", "rand 0.8.5", "rust_decimal", @@ -347,6 +348,27 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg 1.1.0", +] + +[[package]] +name = "nix" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f17df307904acd05aa8e32e97bb20f2a0df1728bbc2d771ae8f9a90463441e9" +dependencies = [ + "bitflags", + "cfg-if", + "libc", + "memoffset", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 19f6b9d2e..8596faef8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["processthreadsapi", "minwindef", "winnt"] } +[target.'cfg(target_os="linux")'.dependencies] +nix = { version = "0.24.1", features = ["zerocopy"] } + [dependencies.clap] version = "3" default-features = false diff --git a/src/benchmark/executor.rs b/src/benchmark/executor.rs index 52017c69f..a8f84a9c6 100644 --- a/src/benchmark/executor.rs +++ b/src/benchmark/executor.rs @@ -36,10 +36,10 @@ pub trait Executor { fn run_command_and_measure_common( mut command: std::process::Command, command_failure_action: CmdFailureAction, - command_output_policy: CommandOutputPolicy, + command_output_policy: &CommandOutputPolicy, command_name: &str, ) -> Result { - let (stdout, stderr) = command_output_policy.get_stdout_stderr(); + let (stdout, stderr) = command_output_policy.get_stdout_stderr()?; command.stdin(Stdio::null()).stdout(stdout).stderr(stderr); command.env( @@ -83,7 +83,7 @@ impl<'a> Executor for RawExecutor<'a> { let result = run_command_and_measure_common( command.get_command()?, command_failure_action.unwrap_or(self.options.command_failure_action), - self.options.command_output_policy, + &self.options.command_output_policy, &command.get_command_line(), )?; @@ -136,7 +136,7 @@ impl<'a> Executor for ShellExecutor<'a> { let mut result = run_command_and_measure_common( command_builder, command_failure_action.unwrap_or(self.options.command_failure_action), - self.options.command_output_policy, + &self.options.command_output_policy, &command.get_command_line(), )?; diff --git a/src/cli.rs b/src/cli.rs index 14ff88481..520fa5ecf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -252,6 +252,23 @@ fn build_command() -> Command<'static> { when trying to benchmark output speed.", ), ) + .arg( + Arg::new("output") + .long("output") + .conflicts_with("show-output") + .takes_value(true) + .value_name("WHERE") + .help( + "Control where the output of the benchmark is redirected. can be:\n\n\ + null: Redirect output to /dev/null (the default). \ + Note that some programs like 'grep' detect when standard output is /dev/null \ + and apply certain optimizations. To avoid that, consider using \ + '--output=pipe'.\n\n\ + pipe: Feed the output through a pipe before discarding it.\n\n\ + inherit: Don't redirect the output at all (same as '--show-output').\n\n\ + : Write the output to the given file.", + ), + ) .arg( Arg::new("command-name") .long("command-name") diff --git a/src/error.rs b/src/error.rs index 19bec56d5..c24e9ecc7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -49,4 +49,6 @@ pub enum OptionsError<'a> { EmptyShell, #[error("Failed to parse '--shell ' expression as command line: {0}")] ShellParseError(shell_words::ParseError), + #[error("Unknown output policy '{0}'. Use './{0}' to output to a file named '{0}'.")] + UnknownOutputPolicy(String), } diff --git a/src/options.rs b/src/options.rs index bad0f3721..7281a6201 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,5 +1,7 @@ +use std::fs::File; +use std::path::PathBuf; use std::process::{Command, Stdio}; -use std::{cmp, fmt}; +use std::{cmp, fmt, io}; use anyhow::ensure; use atty::Stream; @@ -109,27 +111,44 @@ impl Default for RunBounds { } /// How to handle the output of benchmarked commands -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum CommandOutputPolicy { - /// Discard all output - Discard, + /// Redirect output to the null device + Null, + + /// Feed output through a pipe before discarding it + Pipe, + + /// Redirect output to a file + File(PathBuf), /// Show command output on the terminal - Forward, + Inherit, } impl Default for CommandOutputPolicy { fn default() -> Self { - CommandOutputPolicy::Discard + CommandOutputPolicy::Null } } impl CommandOutputPolicy { - pub fn get_stdout_stderr(&self) -> (Stdio, Stdio) { - match self { - CommandOutputPolicy::Discard => (Stdio::null(), Stdio::null()), - CommandOutputPolicy::Forward => (Stdio::inherit(), Stdio::inherit()), - } + pub fn get_stdout_stderr(&self) -> io::Result<(Stdio, Stdio)> { + let streams = match self { + CommandOutputPolicy::Null => (Stdio::null(), Stdio::null()), + + // Typically only stdout is performance-relevant, so just pipe that + CommandOutputPolicy::Pipe => (Stdio::piped(), Stdio::null()), + + CommandOutputPolicy::File(path) => { + let file = File::create(&path)?; + (file.into(), Stdio::null()) + } + + CommandOutputPolicy::Inherit => (Stdio::inherit(), Stdio::inherit()), + }; + + Ok(streams) } } @@ -193,7 +212,7 @@ impl Default for Options { cleanup_command: None, output_style: OutputStyleOption::Full, executor_kind: ExecutorKind::default(), - command_output_policy: CommandOutputPolicy::Discard, + command_output_policy: CommandOutputPolicy::Null, time_unit: None, } } @@ -250,9 +269,22 @@ impl Options { options.cleanup_command = matches.value_of("cleanup").map(String::from); options.command_output_policy = if matches.is_present("show-output") { - CommandOutputPolicy::Forward + CommandOutputPolicy::Inherit + } else if let Some(output) = matches.value_of("output") { + match output { + "null" => CommandOutputPolicy::Null, + "pipe" => CommandOutputPolicy::Pipe, + "inherit" => CommandOutputPolicy::Inherit, + arg => { + let path = PathBuf::from(arg); + if path.components().count() <= 1 { + return Err(OptionsError::UnknownOutputPolicy(arg.to_string())); + } + CommandOutputPolicy::File(path) + } + } } else { - CommandOutputPolicy::Discard + CommandOutputPolicy::Null }; options.output_style = match matches.value_of("style") { @@ -262,12 +294,12 @@ impl Options { Some("color") => OutputStyleOption::Color, Some("none") => OutputStyleOption::Disabled, _ => { - if options.command_output_policy == CommandOutputPolicy::Discard - && atty::is(Stream::Stdout) + if options.command_output_policy == CommandOutputPolicy::Inherit + || !atty::is(Stream::Stdout) { - OutputStyleOption::Full - } else { OutputStyleOption::Basic + } else { + OutputStyleOption::Full } } }; diff --git a/src/timer/mod.rs b/src/timer/mod.rs index 41e3587f2..fd6b88a89 100644 --- a/src/timer/mod.rs +++ b/src/timer/mod.rs @@ -6,10 +6,18 @@ mod windows_timer; #[cfg(not(windows))] mod unix_timer; +#[cfg(target_os = "linux")] +use nix::fcntl::{splice, SpliceFFlags}; +#[cfg(target_os = "linux")] +use std::fs::File; +#[cfg(target_os = "linux")] +use std::os::unix::io::AsRawFd; + use crate::util::units::Second; use wall_clock_timer::WallClockTimer; -use std::process::{Command, ExitStatus}; +use std::io::Read; +use std::process::{ChildStdout, Command, ExitStatus}; use anyhow::Result; @@ -33,26 +41,57 @@ pub struct TimerResult { pub status: ExitStatus, } +/// Discard the output of a child process. +fn discard(output: ChildStdout) { + const CHUNK_SIZE: usize = 64 << 10; + + #[cfg(target_os = "linux")] + { + if let Ok(file) = File::create("/dev/null") { + while let Ok(bytes) = splice( + output.as_raw_fd(), + None, + file.as_raw_fd(), + None, + CHUNK_SIZE, + SpliceFFlags::empty(), + ) { + if bytes == 0 { + break; + } + } + } + } + + let mut output = output; + let mut buf = [0; CHUNK_SIZE]; + while let Ok(bytes) = output.read(&mut buf) { + if bytes == 0 { + break; + } + } +} + /// Execute the given command and return a timing summary pub fn execute_and_measure(mut command: Command) -> Result { let wallclock_timer = WallClockTimer::start(); #[cfg(not(windows))] - let ((time_user, time_system), status) = { - let cpu_timer = self::unix_timer::CPUTimer::start(); - let status = command.status()?; - (cpu_timer.stop(), status) - }; + let cpu_timer = self::unix_timer::CPUTimer::start(); + + let mut child = command.spawn()?; #[cfg(windows)] - let ((time_user, time_system), status) = { - let mut child = command.spawn()?; - let cpu_timer = self::windows_timer::CPUTimer::start_for_process(&child); - let status = child.wait()?; + let cpu_timer = self::windows_timer::CPUTimer::start_for_process(&child); + + if let Some(output) = child.stdout.take() { + // Handle CommandOutputPolicy::Pipe + discard(output); + } - (cpu_timer.stop(), status) - }; + let status = child.wait()?; + let (time_user, time_system) = cpu_timer.stop(); let time_real = wallclock_timer.stop(); Ok(TimerResult {