Skip to main content

rlx_ir/
measure.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Cycle-accurate timing primitive (#66 in plan.md).
6//!
7//! `Instant::now()` on macOS already wraps `mach_continuous_time` (which on
8//! Apple Silicon ultimately reads `CNTVCT_EL0`), so the wall-clock precision
9//! is fine. The win from going direct is two-fold:
10//!
11//!   1. **Resolution.** `Instant` exposes nanoseconds via `Duration`, but the
12//!      hardware tick is ~41 ns on M-series (24 MHz `CNTFRQ_EL0`). Tracking
13//!      raw ticks lets the autotuner reason at the actual hardware grain
14//!      instead of pretending it has 1 ns precision.
15//!   2. **Overhead.** `Instant::now()` is a few hundred cycles of wrapper
16//!      and `Duration` math. The raw `mrs` is one instruction. For
17//!      sub-microsecond kernels (small-tile probes in `calibrate.rs`) the
18//!      wrapper itself becomes a measurable fraction of the timed region.
19//!
20//! Falls back to `Instant` on non-AArch64 / non-Apple targets so the API
21//! stays portable.
22
23#[cfg(all(
24    not(all(target_arch = "aarch64", target_vendor = "apple")),
25    not(target_arch = "wasm32")
26))]
27use std::time::Instant;
28
29/// Opaque tick reading. Subtract two of these to get a `Duration`.
30#[derive(Copy, Clone, Debug)]
31pub struct Tick {
32    #[cfg(all(target_arch = "aarch64", target_vendor = "apple"))]
33    cycles: u64,
34    // wasm32 carries no field: the tick is a no-op (see `now`/`elapsed_ns`).
35    #[cfg(all(
36        not(all(target_arch = "aarch64", target_vendor = "apple")),
37        not(target_arch = "wasm32")
38    ))]
39    instant: Instant,
40}
41
42impl Tick {
43    /// Read the current tick.
44    #[inline(always)]
45    pub fn now() -> Self {
46        #[cfg(all(target_arch = "aarch64", target_vendor = "apple"))]
47        {
48            Tick {
49                cycles: read_cntvct(),
50            }
51        }
52        #[cfg(target_arch = "wasm32")]
53        {
54            // `std::time::Instant` panics on wasm32-unknown-unknown, and a real
55            // clock here would mean a JS `performance.now()` binding. Until one
56            // exists the tick reads nothing and `elapsed_ns` reports 0.
57            Tick {}
58        }
59        #[cfg(all(
60            not(all(target_arch = "aarch64", target_vendor = "apple")),
61            not(target_arch = "wasm32")
62        ))]
63        {
64            Tick {
65                instant: Instant::now(),
66            }
67        }
68    }
69
70    /// Elapsed nanoseconds since `start`. Saturates at zero if the clock
71    /// went backwards (it shouldn't, but the kernel is allowed to lie).
72    #[inline(always)]
73    pub fn elapsed_ns(&self, start: Tick) -> u64 {
74        #[cfg(all(target_arch = "aarch64", target_vendor = "apple"))]
75        {
76            let dt = self.cycles.saturating_sub(start.cycles);
77            // CNTFRQ_EL0 is 24 MHz on Apple Silicon → 1 tick = 1000/24 ns.
78            // Cached the first time we ask; the value never changes.
79            let freq = cntfrq_hz();
80            ((dt as u128) * 1_000_000_000u128 / freq as u128) as u64
81        }
82        #[cfg(target_arch = "wasm32")]
83        {
84            let _ = start;
85            0
86        }
87        #[cfg(all(
88            not(all(target_arch = "aarch64", target_vendor = "apple")),
89            not(target_arch = "wasm32")
90        ))]
91        {
92            self.instant.duration_since(start.instant).as_nanos() as u64
93        }
94    }
95
96    #[inline(always)]
97    pub fn elapsed_us(&self, start: Tick) -> f64 {
98        self.elapsed_ns(start) as f64 / 1_000.0
99    }
100
101    #[inline(always)]
102    pub fn elapsed_ms(&self, start: Tick) -> f64 {
103        self.elapsed_ns(start) as f64 / 1_000_000.0
104    }
105}
106
107#[cfg(all(target_arch = "aarch64", target_vendor = "apple"))]
108#[inline(always)]
109fn read_cntvct() -> u64 {
110    let val: u64;
111    // Safety: `mrs cntvct_el0, X` is unprivileged on AArch64 and reads the
112    // virtual count register. No memory effects.
113    unsafe {
114        std::arch::asm!("mrs {0}, cntvct_el0", out(reg) val, options(nomem, nostack));
115    }
116    val
117}
118
119#[cfg(all(target_arch = "aarch64", target_vendor = "apple"))]
120fn cntfrq_hz() -> u64 {
121    use std::sync::OnceLock;
122    static FREQ: OnceLock<u64> = OnceLock::new();
123    *FREQ.get_or_init(|| {
124        let val: u64;
125        unsafe {
126            std::arch::asm!("mrs {0}, cntfrq_el0", out(reg) val, options(nomem, nostack));
127        }
128        // Apple Silicon reports 24 MHz; guard against bogus zero just in case.
129        if val == 0 { 24_000_000 } else { val }
130    })
131}
132
133/// Time `f`, returning `(result, elapsed_ns)`. Inlined so the surrounding
134/// loop can keep the closure body in registers.
135#[inline(always)]
136pub fn time_ns<R>(f: impl FnOnce() -> R) -> (R, u64) {
137    let t0 = Tick::now();
138    let r = f();
139    let t1 = Tick::now();
140    (r, t1.elapsed_ns(t0))
141}
142
143/// Cache-busting buffer — sized to evict L1+L2 on Apple Silicon
144/// (M-series: 192 KB L1d / core, 16 MB L2 shared per cluster).
145/// Borrowed from MAX's `internal_utils/_cache_busting.mojo` (#19).
146///
147/// Allocate once, then call `.thrash()` between bench iterations to
148/// flush whatever the previous iteration left in cache. Without this,
149/// "cache-cold" timings actually measure cache-warm performance and
150/// over-report by 2-5×.
151pub struct CacheBuster {
152    buf: Vec<u8>,
153}
154
155impl CacheBuster {
156    /// Allocate a buster sized to evict the targeted cache. Defaults
157    /// to 32 MB — twice the M-series L2 — which guarantees full L2
158    /// eviction. Pass a custom size for finer control (e.g. 256 KB
159    /// to evict only L1).
160    pub fn new() -> Self {
161        Self::with_bytes(32 * 1024 * 1024)
162    }
163
164    pub fn with_bytes(bytes: usize) -> Self {
165        Self {
166            buf: vec![0u8; bytes],
167        }
168    }
169
170    /// Walk the buffer once, touching every cache line. After this
171    /// returns, the previous workload's data is evicted.
172    #[inline(never)]
173    pub fn thrash(&mut self) {
174        // 64-byte stride matches the cacheline size on Apple Silicon.
175        // Use a volatile-ish read+write so the optimizer can't elide.
176        let len = self.buf.len();
177        let ptr = self.buf.as_mut_ptr();
178        let mut acc: u8 = 0;
179        let mut i = 0usize;
180        while i < len {
181            unsafe {
182                let p = ptr.add(i);
183                acc = acc.wrapping_add(std::ptr::read_volatile(p));
184                std::ptr::write_volatile(p, acc);
185            }
186            i += 64;
187        }
188        // Write the accumulator somewhere observable so dead-store
189        // elimination doesn't drop the loop on aggressive opt levels.
190        std::hint::black_box(acc);
191    }
192}
193
194impl Default for CacheBuster {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn tick_is_monotonic() {
206        // CNTVCT_EL0 ticks at ~24 MHz on Apple Silicon → ~41 ns per tick.
207        // Two back-to-back reads can land on the same tick. Sleep one
208        // tick period so the delta is guaranteed non-zero.
209        let a = Tick::now();
210        std::thread::sleep(std::time::Duration::from_micros(50));
211        let b = Tick::now();
212        assert!(b.elapsed_ns(a) > 0);
213    }
214
215    #[test]
216    fn elapsed_units_agree() {
217        let a = Tick::now();
218        std::thread::sleep(std::time::Duration::from_millis(2));
219        let b = Tick::now();
220        let ns = b.elapsed_ns(a);
221        assert!(ns >= 1_500_000, "expected >=1.5ms, got {ns}ns");
222        assert!((b.elapsed_ms(a) - ns as f64 / 1e6).abs() < 1e-6);
223    }
224}