From bc3a3f052471fec6a3e84bfac65b8c12dfa133ac Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 6 Oct 2026 08:59:40 +0200 Subject: [PATCH] Support hardware counters (instructions, cache misses, ...) (#983) * Add hardware counters to JSON exports * Clarify Linux hardware counter setup --- Cargo.lock | 10 +++ Cargo.toml | 3 + README.md | 28 ++++++++ src/benchmark/executor.rs | 7 +- src/benchmark/measurement.rs | 44 ++++++++++++ src/export/csv.rs | 6 ++ src/export/json.rs | 126 ++++++++++++++++++++++++++--------- src/export/tests.rs | 24 +++++++ src/quantity/mod.rs | 6 ++ src/quantity/statistics.rs | 10 +++ src/timer/linux_counters.rs | 113 +++++++++++++++++++++++++++++++ src/timer/macos_counters.rs | 55 +++++++++++++++ src/timer/mod.rs | 34 +++++++++- 13 files changed, 432 insertions(+), 34 deletions(-) create mode 100644 src/timer/linux_counters.rs create mode 100644 src/timer/macos_counters.rs diff --git a/Cargo.lock b/Cargo.lock index e81d487..3a54e84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,6 +393,7 @@ dependencies = [ "libc", "nix", "once_cell", + "perf-event-open-sys", "platform-info", "predicates", "rand 0.10.3", @@ -539,6 +540,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "perf-event-open-sys" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5f8d1487a4ffa23c80a1c355dd27235f9b66fb71ba0f261eb417e4fe8451347" +dependencies = [ + "libc", +] + [[package]] name = "platform-info" version = "2.2.0" diff --git a/Cargo.toml b/Cargo.toml index 8a6e7d0..ac47bd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,9 @@ once_cell = "1.21" [target.'cfg(target_os="linux")'.dependencies] nix = { version = "0.31", features = ["zerocopy"] } +[target.'cfg(all(target_os = "linux", target_pointer_width = "64", any(target_arch = "aarch64", target_arch = "x86_64", target_arch = "riscv64", target_arch = "powerpc64", target_arch = "loongarch64")))'.dependencies] +perf-event-open-sys = "6" + [dependencies.clap] version = "4" default-features = false diff --git a/README.md b/README.md index fa26b64..aca3f9b 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,34 @@ The JSON export includes the following metrics for each measured run (excluding *not the simultaneous total memory of the full process tree*. - Windows: Currently not supported. +- **`cpu_cycles`**: CPU cycles consumed. + + - Linux: Includes threads and child processes, but excludes kernel and hypervisor execution. + - macOS: Includes the process's threads and kernel execution, but excludes child processes. + - Windows: Currently not supported. + +- **`instructions`**: Completed CPU instructions. + + - Linux/macOS: Same scope as `cpu_cycles`. + - Windows: Currently not supported. + +- **`cache_references`**: Cache accesses counted by the CPU's generic cache event. + + - Linux: Same scope as `cpu_cycles`. Cache-event definitions depend on the CPU. + - macOS/Windows: Currently not supported. + +- **`cache_misses`**: Cache misses. + + - Linux: Same scope as `cpu_cycles`. Cache-event definitions depend on the CPU. + - macOS/Windows: Currently not supported. + +- **`branch_misses`**: Mispredicted branches. + + - Linux: Same scope as `cpu_cycles`. + - macOS/Windows: Currently not supported. + +Hardware counters are not available if a `--shell` is used. + The JSON output is useful if you want to analyze the benchmark results in more detail. The [`scripts/`](https://github.com/sharkdp/hyperfine/tree/master/scripts) folder includes a lot of helpful Python programs to further analyze benchmark results and create helpful diff --git a/src/benchmark/executor.rs b/src/benchmark/executor.rs index de077b7..762204b 100644 --- a/src/benchmark/executor.rs +++ b/src/benchmark/executor.rs @@ -62,6 +62,7 @@ fn run_command_and_measure_common( command_input_policy: &CommandInputPolicy, command_output_policy: &CommandOutputPolicy, command_name: &str, + collect_hardware_counters: bool, ) -> Result { let stdin = command_input_policy.get_stdin()?; let (stdout, stderr) = command_output_policy.get_stdout_stderr()?; @@ -76,7 +77,7 @@ fn run_command_and_measure_common( command.env("HYPERFINE_ITERATION", value); } - let measurement = execute_and_measure(command) + let measurement = execute_and_measure(command, collect_hardware_counters) .with_context(|| format!("Failed to run command '{command_name}'"))?; if !measurement.exit_status.success() { @@ -144,6 +145,7 @@ impl Executor for RawExecutor<'_> { &self.options.command_input_policy, output_policy, &command.get_command_line(), + matches!(iteration, BenchmarkIteration::Benchmark(_)), ) } @@ -199,6 +201,7 @@ impl Executor for ShellExecutor<'_> { &self.options.command_input_policy, output_policy, &command.get_command_line(), + false, )?; // Subtract shell spawning time @@ -278,6 +281,7 @@ impl Executor for ShellExecutor<'_> { time_user: measurements.time_user_mean(), time_system: measurements.time_system_mean(), memory_peak_resident: None, + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }); @@ -336,6 +340,7 @@ impl Executor for MockExecutor { time_user: Time::zero(), time_system: Time::zero(), memory_peak_resident: Some(Information::zero()), + hardware_counters: Default::default(), exit_status, }) } diff --git a/src/benchmark/measurement.rs b/src/benchmark/measurement.rs index 0c2e4ea..d1ec75f 100644 --- a/src/benchmark/measurement.rs +++ b/src/benchmark/measurement.rs @@ -29,6 +29,47 @@ where } } +fn serialize_optional_count(value: &Option, serializer: S) -> Result +where + S: serde::Serializer, +{ + #[derive(Serialize)] + struct Count { + value: u64, + } + + value.map(|value| Count { value }).serialize(serializer) +} + +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct HardwareCounters { + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_count" + )] + pub cpu_cycles: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_count" + )] + pub instructions: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_count" + )] + pub cache_references: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_count" + )] + pub cache_misses: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_count" + )] + pub branch_misses: Option, +} + /// Performance metric measurements and exit code for a single run #[derive(Debug, Clone, Default, Serialize, PartialEq)] pub struct Measurement { @@ -51,6 +92,9 @@ pub struct Measurement { )] pub memory_peak_resident: Option, + #[serde(flatten)] + pub hardware_counters: HardwareCounters, + // The exit status of the process #[serde(rename = "exit_code", serialize_with = "serialize_exit_status")] pub exit_status: ExitStatus, diff --git a/src/export/csv.rs b/src/export/csv.rs index 5a02fdc..85d6194 100644 --- a/src/export/csv.rs +++ b/src/export/csv.rs @@ -89,6 +89,7 @@ fn test_csv() { time_user: Time::new::(7.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -96,6 +97,7 @@ fn test_csv() { time_user: Time::new::(8.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -103,6 +105,7 @@ fn test_csv() { time_user: Time::new::(12.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -133,6 +136,7 @@ fn test_csv() { time_user: Time::new::(17.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -140,6 +144,7 @@ fn test_csv() { time_user: Time::new::(18.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -147,6 +152,7 @@ fn test_csv() { time_user: Time::new::(19.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), diff --git a/src/export/json.rs b/src/export/json.rs index e4c252b..3b39ce5 100644 --- a/src/export/json.rs +++ b/src/export/json.rs @@ -8,7 +8,7 @@ use crate::benchmark::benchmark_result::BenchmarkResult; use crate::benchmark::measurement::Measurements; use crate::options::SortOrder; use crate::quantity::statistics::{max, mean, median, min, standard_deviation, UnsafeRawValue}; -use crate::quantity::{byte, second, QuantityInUnit, Ratio, TimeUnit, Zero}; +use crate::quantity::{byte, ratio, second, QuantityInUnit, Ratio, TimeUnit, Zero}; use anyhow::Result; @@ -40,36 +40,92 @@ struct BenchmarkSummary { time_system: StatisticalSummary, #[serde(skip_serializing_if = "Option::is_none")] memory_peak_resident: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cpu_cycles: Option, + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_references: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_misses: Option, + #[serde(skip_serializing_if = "Option::is_none")] + branch_misses: Option, } impl BenchmarkSummary { fn from_measurements(measurements: &Measurements) -> Self { let values = &measurements.measurements; - let mut memory_values = values - .iter() - .filter_map(|m| m.memory_peak_resident) - .peekable(); Self { time_wall_clock: StatisticalSummary::from_values( - values.iter().map(|m| m.time_wall_clock), - second, - ), - time_user: StatisticalSummary::from_values(values.iter().map(|m| m.time_user), second), + values.iter().map(|m| Some(m.time_wall_clock)), + Some(second), + ) + .expect("Benchmarks have at least one measurement"), + time_user: StatisticalSummary::from_values( + values.iter().map(|m| Some(m.time_user)), + Some(second), + ) + .expect("Benchmarks have at least one measurement"), time_system: StatisticalSummary::from_values( - values.iter().map(|m| m.time_system), - second, + values.iter().map(|m| Some(m.time_system)), + Some(second), + ) + .expect("Benchmarks have at least one measurement"), + memory_peak_resident: StatisticalSummary::from_values( + values + .iter() + .filter_map(|m| m.memory_peak_resident) + .map(Some), + Some(byte), + ), + cpu_cycles: StatisticalSummary::from_values( + values.iter().map(|m| { + m.hardware_counters + .cpu_cycles + .map(|v| Ratio::new::(v as f64)) + }), + None::, + ), + instructions: StatisticalSummary::from_values( + values.iter().map(|m| { + m.hardware_counters + .instructions + .map(|v| Ratio::new::(v as f64)) + }), + None::, + ), + cache_references: StatisticalSummary::from_values( + values.iter().map(|m| { + m.hardware_counters + .cache_references + .map(|v| Ratio::new::(v as f64)) + }), + None::, + ), + cache_misses: StatisticalSummary::from_values( + values.iter().map(|m| { + m.hardware_counters + .cache_misses + .map(|v| Ratio::new::(v as f64)) + }), + None::, + ), + branch_misses: StatisticalSummary::from_values( + values.iter().map(|m| { + m.hardware_counters + .branch_misses + .map(|v| Ratio::new::(v as f64)) + }), + None::, ), - memory_peak_resident: memory_values - .peek() - .is_some() - .then(|| StatisticalSummary::from_values(memory_values, byte)), } } } #[derive(Serialize)] struct StatisticalSummary { - unit: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + unit: Option<&'static str>, count: usize, mean: f64, stddev: Option, @@ -79,7 +135,10 @@ struct StatisticalSummary { } impl StatisticalSummary { - fn from_values(values: impl IntoIterator, _unit: U) -> Self + fn from_values( + values: impl IntoIterator>, + unit: Option, + ) -> Option where Q: Copy + PartialOrd @@ -92,13 +151,13 @@ impl StatisticalSummary { P: Into, U: uom::si::Unit, { - let values: Vec<_> = values.into_iter().collect(); - assert!( - !values.is_empty(), - "Statistical summaries require at least one measurement" - ); - Self { - unit: U::singular(), + // Only summarize a metric when it is available for every supplied measurement. + let values: Vec<_> = values.into_iter().collect::>()?; + if values.is_empty() { + return None; + } + Some(Self { + unit: unit.map(|_| U::singular()), count: values.len(), mean: mean(values.iter().copied()).value_in_unit(), stddev: (values.len() > 1) @@ -106,7 +165,7 @@ impl StatisticalSummary { median: median(values.iter().copied()).value_in_unit(), min: min(values.iter().copied()).value_in_unit(), max: max(values.iter().copied()).value_in_unit(), - } + }) } } @@ -149,22 +208,25 @@ fn test_statistical_summary_unit_conversion() { use crate::quantity::{kibibyte, millisecond, Information, Time}; let time = StatisticalSummary::from_values( - [Time::new::(1.0), Time::new::(3.0)], - millisecond, - ); + [Time::new::(1.0), Time::new::(3.0)].map(Some), + Some(millisecond), + ) + .unwrap(); let information = StatisticalSummary::from_values( [ Information::new::(1024.0), Information::new::(3072.0), - ], - kibibyte, - ); + ] + .map(Some), + Some(kibibyte), + ) + .unwrap(); for (summary, unit, scale) in [ (time, "millisecond", 1000.0), (information, "kibibyte", 1.0), ] { - assert_eq!(summary.unit, unit); + assert_eq!(summary.unit, Some(unit)); assert_eq!(summary.count, 2); approx::assert_relative_eq!(summary.mean, 2.0 * scale); approx::assert_relative_eq!(summary.median, 2.0 * scale); diff --git a/src/export/tests.rs b/src/export/tests.rs index d99d51a..6c7ca48 100644 --- a/src/export/tests.rs +++ b/src/export/tests.rs @@ -36,6 +36,7 @@ fn test_markup_export_auto_ms() { time_user: Time::new::(0.09), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -43,6 +44,7 @@ fn test_markup_export_auto_ms() { time_user: Time::new::(0.10), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -50,6 +52,7 @@ fn test_markup_export_auto_ms() { time_user: Time::new::(0.14), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -65,6 +68,7 @@ fn test_markup_export_auto_ms() { time_user: Time::new::(2.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -72,6 +76,7 @@ fn test_markup_export_auto_ms() { time_user: Time::new::(3.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -79,6 +84,7 @@ fn test_markup_export_auto_ms() { time_user: Time::new::(4.0), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -139,6 +145,7 @@ fn test_markup_export_auto_s() { time_user: Time::new::(2.1), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -146,6 +153,7 @@ fn test_markup_export_auto_s() { time_user: Time::new::(2.2), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -153,6 +161,7 @@ fn test_markup_export_auto_s() { time_user: Time::new::(2.3), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -168,6 +177,7 @@ fn test_markup_export_auto_s() { time_user: Time::new::(0.1), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -175,6 +185,7 @@ fn test_markup_export_auto_s() { time_user: Time::new::(0.2), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -182,6 +193,7 @@ fn test_markup_export_auto_s() { time_user: Time::new::(0.3), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -242,6 +254,7 @@ fn test_markup_export_manual_ms() { time_user: Time::new::(2.1), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -249,6 +262,7 @@ fn test_markup_export_manual_ms() { time_user: Time::new::(2.2), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -256,6 +270,7 @@ fn test_markup_export_manual_ms() { time_user: Time::new::(2.3), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -271,6 +286,7 @@ fn test_markup_export_manual_ms() { time_user: Time::new::(0.1), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -278,6 +294,7 @@ fn test_markup_export_manual_ms() { time_user: Time::new::(0.2), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -285,6 +302,7 @@ fn test_markup_export_manual_ms() { time_user: Time::new::(0.3), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -344,6 +362,7 @@ fn test_markup_export_manual_s() { time_user: Time::new::(2.01), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -351,6 +370,7 @@ fn test_markup_export_manual_s() { time_user: Time::new::(2.02), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -358,6 +378,7 @@ fn test_markup_export_manual_s() { time_user: Time::new::(2.03), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), @@ -373,6 +394,7 @@ fn test_markup_export_manual_s() { time_user: Time::new::(0.11), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -380,6 +402,7 @@ fn test_markup_export_manual_s() { time_user: Time::new::(0.12), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, Measurement { @@ -387,6 +410,7 @@ fn test_markup_export_manual_s() { time_user: Time::new::(0.13), time_system: Time::zero(), memory_peak_resident: Some(Information::new::(1024.)), + hardware_counters: Default::default(), exit_status: ExitStatus::default(), }, ]), diff --git a/src/quantity/mod.rs b/src/quantity/mod.rs index 98ed20e..e405b7b 100644 --- a/src/quantity/mod.rs +++ b/src/quantity/mod.rs @@ -34,6 +34,12 @@ impl> QuantityInUn } } +impl> QuantityInUnit for Ratio { + fn value_in_unit(self) -> f64 { + self.get::() + } +} + pub trait FormatQuantity { type Unit; diff --git a/src/quantity/statistics.rs b/src/quantity/statistics.rs index e9c0c62..695ea14 100644 --- a/src/quantity/statistics.rs +++ b/src/quantity/statistics.rs @@ -91,6 +91,16 @@ impl UnsafeRawValue for Information { } } +impl UnsafeRawValue for Ratio { + fn unsafe_raw_value(&self) -> f64 { + self.get::() + } + + fn unsafe_from_raw_value(value: f64) -> Self { + Ratio::new::(value) + } +} + fn standard_deviation_f64(values: &[f64]) -> f64 { let mean_value = mean(values.iter().copied()); diff --git a/src/timer/linux_counters.rs b/src/timer/linux_counters.rs new file mode 100644 index 0000000..8b5c63b --- /dev/null +++ b/src/timer/linux_counters.rs @@ -0,0 +1,113 @@ +use std::fs::File; +use std::io::Read; + +use crate::benchmark::measurement::HardwareCounters; + +/// Disabled events on the spawning thread, inherited and enabled by the child's exec. +/// The parent never enables its own events, so its work is not counted. +pub struct CounterGroup { + events: [Option; 5], +} + +impl CounterGroup { + #[cfg(all( + target_pointer_width = "64", + any( + target_arch = "aarch64", + target_arch = "x86_64", + target_arch = "riscv64", + target_arch = "powerpc64", + target_arch = "loongarch64" + ) + ))] + pub fn new() -> Self { + use perf_event_open_sys::{bindings, perf_event_open}; + use std::os::fd::{AsRawFd, FromRawFd}; + + let mut group_leader = -1; + let events = [ + bindings::PERF_COUNT_HW_CPU_CYCLES, + bindings::PERF_COUNT_HW_INSTRUCTIONS, + bindings::PERF_COUNT_HW_CACHE_REFERENCES, + bindings::PERF_COUNT_HW_CACHE_MISSES, + bindings::PERF_COUNT_HW_BRANCH_MISSES, + ] + .map(|config| { + let mut attr = bindings::perf_event_attr { + type_: bindings::PERF_TYPE_HARDWARE, + size: std::mem::size_of::() as u32, + config: u64::from(config), + ..Default::default() + }; + // Don't start counting immediately. + attr.set_disabled(1); + // Start counting when the child executes the benchmark program. + attr.set_enable_on_exec(1); + // New child processes and threads inherit the counters. + attr.set_inherit(1); + // Exclude kernel execution. + attr.set_exclude_kernel(1); + // Exclude hypervisor execution. + attr.set_exclude_hv(1); + + // Attach counters to the calling thread. + const CURRENT_THREAD: libc::pid_t = 0; + // Count on whichever CPU the task runs on. + const ANY_CPU: libc::c_int = -1; + + // SAFETY: attr is initialized and sized for the kernel API. + let fd = unsafe { + perf_event_open( + &mut attr, + CURRENT_THREAD, + ANY_CPU, + group_leader, + bindings::PERF_FLAG_FD_CLOEXEC.into(), + ) + }; + if fd < 0 { + return None; + } + // SAFETY: perf_event_open returned a new, valid descriptor. + let event = unsafe { File::from_raw_fd(fd) }; + if group_leader == -1 { + group_leader = event.as_raw_fd(); + } + Some(event) + }); + Self { events } + } + + #[cfg(not(all( + target_pointer_width = "64", + any( + target_arch = "aarch64", + target_arch = "x86_64", + target_arch = "riscv64", + target_arch = "powerpc64", + target_arch = "loongarch64" + ) + )))] + pub fn new() -> Self { + Self { + events: Default::default(), + } + } + + /// Read raw counts after the child has exited. Like poop, do not scale for multiplexing. + pub fn read(mut self) -> HardwareCounters { + let [cpu_cycles, instructions, cache_references, cache_misses, branch_misses] = + self.events.each_mut().map(|event| { + let mut bytes = [0; 8]; + event.as_mut()?.read_exact(&mut bytes).ok()?; + Some(u64::from_ne_bytes(bytes)) + }); + HardwareCounters { + cpu_cycles, + instructions, + cache_references, + cache_misses, + branch_misses, + } + } +} diff --git a/src/timer/macos_counters.rs b/src/timer/macos_counters.rs new file mode 100644 index 0000000..91b80c4 --- /dev/null +++ b/src/timer/macos_counters.rs @@ -0,0 +1,55 @@ +use std::io; +use std::mem::MaybeUninit; +use std::process::Child; + +use crate::benchmark::measurement::HardwareCounters; + +/// Wait for exit without reaping: proc_pid_rusage can still inspect the zombie, +/// and the existing wait4 call can subsequently collect CPU times and peak RSS. +pub fn wait(child: &mut Child) -> io::Result<()> { + drop(child.stdin.take()); + let mut info = MaybeUninit::uninit(); + loop { + // SAFETY: info points to writable siginfo_t storage. WNOWAIT preserves the child. + let result = unsafe { + libc::waitid( + libc::P_PID, + child.id(), + info.as_mut_ptr(), + libc::WEXITED | libc::WNOWAIT, + ) + }; + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } +} + +pub fn read(pid: u32) -> HardwareCounters { + let mut usage = MaybeUninit::::zeroed(); + // SAFETY: the buffer has the layout and size requested by RUSAGE_INFO_V4. + // Despite the pointer-to-pointer signature, this API writes directly into the buffer. + let result = unsafe { + libc::proc_pid_rusage( + pid as libc::pid_t, + libc::RUSAGE_INFO_V4, + usage.as_mut_ptr().cast(), + ) + }; + if result != 0 { + return HardwareCounters::default(); + } + // SAFETY: the call succeeded and initialized the buffer. + let usage = unsafe { usage.assume_init() }; + HardwareCounters { + // Like macOS time(1), omit zero counters: the API can succeed on systems + // where instruction/cycle accounting is unavailable. + cpu_cycles: (usage.ri_cycles != 0).then_some(usage.ri_cycles), + instructions: (usage.ri_instructions != 0).then_some(usage.ri_instructions), + ..Default::default() + } +} diff --git a/src/timer/mod.rs b/src/timer/mod.rs index 4d38a8a..d723604 100644 --- a/src/timer/mod.rs +++ b/src/timer/mod.rs @@ -1,5 +1,10 @@ mod wall_clock_timer; +#[cfg(target_os = "linux")] +mod linux_counters; +#[cfg(target_os = "macos")] +mod macos_counters; + #[cfg(windows)] mod windows_timer; @@ -56,7 +61,12 @@ fn discard(output: ChildStdout) { } /// Execute the given command and return a timing summary -pub fn execute_and_measure(mut command: Command) -> Result { +pub fn execute_and_measure( + mut command: Command, + _collect_hardware_counters: bool, +) -> Result { + #[cfg(target_os = "linux")] + let counters = _collect_hardware_counters.then(linux_counters::CounterGroup::new); #[cfg(not(windows))] let cpu_timer = self::unix_timer::CPUTimer::start(); @@ -82,14 +92,36 @@ pub fn execute_and_measure(mut command: Command) -> Result { discard(output); } + #[cfg(target_os = "macos")] + let (time_wall_clock, hardware_counters) = + if _collect_hardware_counters && macos_counters::wait(&mut child).is_ok() { + // Stop timing before querying counters, but leave the child available for wait4. + ( + Some(wallclock_timer.stop()), + macos_counters::read(child.id()), + ) + } else { + (None, Default::default()) + }; let (time_user, time_system, memory_peak_resident, exit_status) = cpu_timer.stop(child)?; + #[cfg(target_os = "macos")] + let time_wall_clock = time_wall_clock.unwrap_or_else(|| wallclock_timer.stop()); + #[cfg(not(target_os = "macos"))] let time_wall_clock = wallclock_timer.stop(); + #[cfg(target_os = "linux")] + let hardware_counters = counters + .map(linux_counters::CounterGroup::read) + .unwrap_or_default(); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let hardware_counters = Default::default(); + Ok(Measurement { time_wall_clock, time_user, time_system, memory_peak_resident, + hardware_counters, exit_status, }) }