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