Skip to main content

fluxbench_core/
measure.rs

1//! High-Precision Timing
2//!
3//! Uses RDTSCP on x86_64 and CNTVCT_EL0 on AArch64 for minimal overhead
4//! cycle counting, with fallback to std::time::Instant on other platforms.
5
6use std::sync::OnceLock;
7use std::time::Duration;
8
9// ─── Inline cycle counter helpers ────────────────────────────────────────────
10
11/// Read the CPU cycle/tick counter (platform-specific).
12#[cfg(target_arch = "x86_64")]
13#[inline(always)]
14fn read_cycles() -> u64 {
15    // SAFETY: RDTSCP is available on all x86_64 CPUs since ~2006.
16    // It is serializing by design — waits for all prior instructions
17    // to complete before reading the cycle counter.
18    unsafe {
19        let mut _aux: u32 = 0;
20        std::arch::x86_64::__rdtscp(&mut _aux)
21    }
22}
23
24/// Read the virtual counter timer on AArch64 (comparable to x86 TSC).
25#[cfg(target_arch = "aarch64")]
26#[inline(always)]
27fn read_cycles() -> u64 {
28    let cnt: u64;
29    // SAFETY: CNTVCT_EL0 is accessible from EL0 (userspace) on all
30    // AArch64 implementations. It provides a monotonically increasing
31    // counter at a fixed frequency (typically the system timer frequency).
32    unsafe {
33        std::arch::asm!("mrs {}, cntvct_el0", out(reg) cnt, options(nostack, nomem));
34    }
35    cnt
36}
37
38#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
39#[inline(always)]
40fn read_cycles() -> u64 {
41    0
42}
43
44/// Whether this platform provides real cycle counters.
45pub const HAS_CYCLE_COUNTER: bool = cfg!(target_arch = "x86_64") || cfg!(target_arch = "aarch64");
46
47// ─── Instant ─────────────────────────────────────────────────────────────────
48
49/// High-precision instant for benchmarking
50#[derive(Debug, Clone, Copy)]
51pub struct Instant {
52    instant: std::time::Instant,
53    tsc: u64,
54}
55
56impl Instant {
57    /// Capture current instant
58    #[inline(always)]
59    pub fn now() -> Self {
60        let tsc = read_cycles();
61        Self {
62            instant: std::time::Instant::now(),
63            tsc,
64        }
65    }
66
67    /// Compute elapsed time since this instant
68    #[inline(always)]
69    pub fn elapsed(&self) -> Duration {
70        self.instant.elapsed()
71    }
72
73    /// Raw cycle/tick count (non-zero on x86_64 and aarch64)
74    #[inline(always)]
75    pub fn cycles(&self) -> u64 {
76        self.tsc
77    }
78}
79
80// ─── Timer ───────────────────────────────────────────────────────────────────
81
82/// Timer for measuring benchmark iterations
83pub struct Timer {
84    start: Instant,
85    cycles_start: u64,
86}
87
88impl Timer {
89    /// Start a new timer
90    #[inline(always)]
91    pub fn start() -> Self {
92        let cycles_start = read_cycles();
93        Self {
94            start: Instant::now(),
95            cycles_start,
96        }
97    }
98
99    /// Stop the timer and return elapsed nanoseconds and cycles
100    #[inline(always)]
101    pub fn stop(&self) -> (u64, u64) {
102        let elapsed = self.start.elapsed();
103        let nanos = elapsed.as_nanos() as u64;
104        let cycles = read_cycles().saturating_sub(self.cycles_start);
105        (nanos, cycles)
106    }
107}
108
109// ─── Timer overhead calibration ──────────────────────────────────────────────
110
111/// Empty timer pairs timed per calibration batch.
112const CALIBRATION_PAIRS: usize = 1_000;
113
114/// Calibration batches. The cheapest batch is the least disturbed one.
115const CALIBRATION_BATCHES: usize = 20;
116
117/// Cost of one empty `Timer::start` + `Timer::stop` pair: (nanoseconds, cycles).
118static TIMER_OVERHEAD: OnceLock<(f64, f64)> = OnceLock::new();
119
120/// Time batches of empty timer pairs and return the cheapest per-pair cost.
121///
122/// An outer timer spans each whole batch. A coarse clock, such as the 100 ns
123/// Windows `Instant`, then still resolves the per-pair cost.
124fn calibrate_timer() -> (f64, f64) {
125    let mut best_ns = f64::MAX;
126    let mut best_cycles = f64::MAX;
127    for _ in 0..CALIBRATION_BATCHES {
128        let outer = Timer::start();
129        for _ in 0..CALIBRATION_PAIRS {
130            let timer = Timer::start();
131            std::hint::black_box(std::hint::black_box(&timer).stop());
132        }
133        let (ns, cycles) = outer.stop();
134        best_ns = best_ns.min(ns as f64 / CALIBRATION_PAIRS as f64);
135        best_cycles = best_cycles.min(cycles as f64 / CALIBRATION_PAIRS as f64);
136    }
137    (best_ns, best_cycles)
138}
139
140/// Nanoseconds one empty timer pair costs.
141///
142/// The first call calibrates from batches of empty pairs. Later calls read the cached value.
143/// The engine subtracts this once per timed region.
144pub fn timer_overhead_ns() -> f64 {
145    TIMER_OVERHEAD.get_or_init(calibrate_timer).0
146}
147
148/// Cycles one empty timer pair costs. It is 0 without a cycle counter.
149///
150/// It shares the calibration and cache with `timer_overhead_ns`.
151pub fn cycles_overhead() -> f64 {
152    TIMER_OVERHEAD.get_or_init(calibrate_timer).1
153}
154
155/// Set CPU affinity to pin the current thread to a specific core
156///
157/// This improves TSC stability by avoiding core migrations.
158#[cfg(target_os = "linux")]
159pub fn pin_to_cpu(cpu: usize) -> Result<(), std::io::Error> {
160    use std::mem::MaybeUninit;
161
162    unsafe {
163        let mut set = MaybeUninit::<libc::cpu_set_t>::zeroed();
164        let set_ref = set.assume_init_mut();
165
166        libc::CPU_ZERO(set_ref);
167        libc::CPU_SET(cpu, set_ref);
168
169        let result = libc::sched_setaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), set_ref);
170
171        if result == 0 {
172            Ok(())
173        } else {
174            Err(std::io::Error::last_os_error())
175        }
176    }
177}
178
179#[cfg(not(target_os = "linux"))]
180pub fn pin_to_cpu(_cpu: usize) -> Result<(), std::io::Error> {
181    // CPU pinning not supported on this platform
182    Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_instant_elapsed() {
191        let start = Instant::now();
192        std::thread::sleep(Duration::from_millis(10));
193        let elapsed = start.elapsed();
194
195        // Should be at least 10ms
196        assert!(elapsed >= Duration::from_millis(5));
197        // Should be less than 100ms (accounting for scheduling)
198        assert!(elapsed < Duration::from_millis(100));
199    }
200
201    #[test]
202    fn test_timer() {
203        let timer = Timer::start();
204        std::thread::sleep(Duration::from_millis(10));
205        let (nanos, _cycles) = timer.stop();
206
207        // Should be at least 5ms in nanos
208        assert!(nanos >= 5_000_000);
209    }
210
211    #[test]
212    fn timer_overhead_calibrated_positive_and_small() {
213        let ns = timer_overhead_ns();
214        assert!(ns > 0.0, "timer overhead is {ns} ns");
215        assert!(ns < 1000.0, "timer overhead is {ns} ns");
216        // The cached value is stable across calls.
217        assert_eq!(ns, timer_overhead_ns());
218        assert!(cycles_overhead() >= 0.0);
219    }
220
221    #[test]
222    fn test_cycle_counter() {
223        if HAS_CYCLE_COUNTER {
224            let a = read_cycles();
225            let b = read_cycles();
226            assert!(b >= a, "cycle counter should be monotonic");
227        }
228    }
229}