Skip to main content

tract_linalg/
cache.rs

1//! Best-effort runtime CPU data-cache geometry detection.
2//!
3//! Cache blocking (panel-block sizing in `mmm`, im2col lowering thresholds, …)
4//! is only correct when the block budget is derived from the *actual* cache the
5//! code runs on, not a hard-coded constant. This module centralises that
6//! detection so every heuristic reads the same memoised numbers instead of each
7//! re-implementing a platform probe.
8//!
9//! All sizes are **bytes**, with `0` meaning "could not detect on this platform"
10//! — callers must treat `0` as unknown and fall back conservatively (never
11//! over-block a cache you cannot see). The raw fields stay honest; the
12//! `*_or_default` helpers apply an architecture-based guess for callers that
13//! prefer a number to a zero.
14//!
15//! Detection is done once, lazily, and cached for the process lifetime.
16
17use std::sync::OnceLock;
18
19/// Detected data-cache sizes in bytes. `0` == unknown on this platform.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct CacheInfo {
22    /// L1 data cache (per core), bytes. `0` if unknown.
23    pub l1_data: usize,
24    /// L2 cache (per perf-core / cluster), bytes. `0` if unknown.
25    pub l2: usize,
26    /// L3 / last-level cache, bytes. `0` if unknown.
27    pub l3: usize,
28    /// How many physical cores share one L2 (1 == private per core, as on most
29    /// server/mobile Arm and x86). Greater than 1 on cluster-shared-L2 parts
30    /// (Cortex-A9/A53). `0` if the topology could not be read — callers treat
31    /// that as private. SMT siblings do not count: they already share the core's
32    /// L2, so a 2-thread core with a private L2 reports 1, not 2.
33    pub l2_sharers: usize,
34}
35
36impl CacheInfo {
37    /// L1 data cache, or an architecture-based guess when undetected
38    /// (64 KiB on arm64, 32 KiB elsewhere — matches common silicon).
39    pub fn l1_data_or_default(&self) -> usize {
40        if self.l1_data > 0 {
41            self.l1_data
42        } else if cfg!(target_arch = "aarch64") {
43            64 * 1024
44        } else {
45            32 * 1024
46        }
47    }
48
49    /// L2 cache, or a conservative 256 KiB guess when undetected.
50    pub fn l2_or_default(&self) -> usize {
51        if self.l2 > 0 { self.l2 } else { 256 * 1024 }
52    }
53
54    /// Physical cores sharing one L2, at least 1 — unknown topology (`0`) reads
55    /// as private, the regression-safe assumption (no shared-cache division).
56    pub fn l2_sharers_or_one(&self) -> usize {
57        self.l2_sharers.max(1)
58    }
59}
60
61/// Memoised cache geometry for the current machine. Detected once on first call.
62pub fn cache_info() -> CacheInfo {
63    static CACHE: OnceLock<CacheInfo> = OnceLock::new();
64    *CACHE.get_or_init(detect)
65}
66
67/// Where the last-level cache used for the outer GEMM blocking tier comes from,
68/// which implies how aggressively a single thread may budget it.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum LlcKind {
71    /// Architectural cluster L3 (or an operator-provided size) — effectively
72    /// private to the CPU, so a single thread can assume most of it.
73    Dedicated,
74    /// System-Level Cache: an interconnect cache shared with the GPU/NPU/display
75    /// (e.g. Qualcomm LLCC, Apple SLC). Contended — budget it conservatively.
76    SystemLevel,
77}
78
79/// Size (bytes) and kind of the last-level cache to size the outer GEMM blocking
80/// tier against, or `None` when nothing usefully larger than L2 is known.
81///
82/// Resolution order (first hit wins):
83///  1. `TRACT_LLC_BYTES` env override (e.g. `"8M"`, `"33554432"`) — for embedders
84///     who know their SoC's LLC/SLC when the OS doesn't expose it. Marked
85///     [`LlcKind::SystemLevel`] iff `TRACT_LLC_CONTENDED` is set, else `Dedicated`.
86///  2. architecturally-detected L3 ([`CacheInfo::l3`]) when it exceeds L2 — `Dedicated`.
87///  3. a System-Level Cache discovered via the Linux devicetree (`cache-level == 3`
88///     with a `cache-size`, outside `/cpus`) — `SystemLevel`.
89///
90/// The per-CPU `cpu/cache/index*` topology the L3 probe reads does **not** list an
91/// SLC (it's a separate interconnect IP), which is why an SLC needs a distinct
92/// source. Prior art — runtime cache sizing: Eigen `queryCacheSizes` (CPUID/sysctl),
93/// glibc `sysconf(_SC_LEVELx_CACHE_SIZE)`, ACPI PPTT, hwloc. SLC exposure: Qualcomm
94/// LLCC (`drivers/soc/qcom/llcc-qcom.c`, devicetree `qcom,llcc`) and the generic
95/// devicetree cache bindings.
96pub fn last_level_cache() -> Option<(usize, LlcKind)> {
97    // Memoised for the process lifetime: this sits on the per-matmul block-sizing
98    // path, and the inputs (env overrides + the devicetree SLC probe) are static.
99    // Recomputing per call cost an env lock + a full recursive devicetree walk on
100    // every GEMM — catastrophic on Arm SoCs with a large devicetree (orders of
101    // magnitude slowdown), negligible elsewhere. Detect once, like `cache_info`.
102    static LLC: OnceLock<Option<(usize, LlcKind)>> = OnceLock::new();
103    *LLC.get_or_init(|| {
104        let ci = cache_info();
105        let override_bytes = env_llc_override();
106        // Lazy: the devicetree walk only runs when neither an env override nor an
107        // architectural L3 (> L2) would already win below, so a normal-L3 part
108        // never pays for the recursive filesystem probe.
109        let slc =
110            if override_bytes.is_some() || ci.l3 > ci.l2 { 0 } else { system_level_cache_bytes() };
111        resolve_llc(
112            override_bytes,
113            std::env::var_os("TRACT_LLC_CONTENDED").is_some(),
114            ci.l2,
115            ci.l3,
116            slc,
117        )
118    })
119}
120
121/// Pure resolution of [`last_level_cache`] (factored out so it is testable without
122/// touching process-global env / hardware).
123fn resolve_llc(
124    override_bytes: Option<usize>,
125    override_contended: bool,
126    l2: usize,
127    l3: usize,
128    slc: usize,
129) -> Option<(usize, LlcKind)> {
130    if let Some(b) = override_bytes.filter(|b| *b > 0) {
131        let kind = if override_contended { LlcKind::SystemLevel } else { LlcKind::Dedicated };
132        return Some((b, kind));
133    }
134    if l3 > l2 {
135        return Some((l3, LlcKind::Dedicated));
136    }
137    if slc > l2 && slc > 0 {
138        return Some((slc, LlcKind::SystemLevel));
139    }
140    None
141}
142
143fn env_llc_override() -> Option<usize> {
144    let b = parse_cache_size(&std::env::var("TRACT_LLC_BYTES").ok()?);
145    (b > 0).then_some(b)
146}
147
148/// Best-effort System-Level Cache size (bytes) from the Linux devicetree: the
149/// largest node carrying `cache-level == 3` *and* a `cache-size`, outside the
150/// `/cpus` subtree (so it is an interconnect cache, not a CPU cache the L3 probe
151/// already saw). Returns `0` when unavailable — e.g. SLCs whose size is fixed in
152/// the controller (Qualcomm LLCC) carry no `cache-size` here, so those still rely
153/// on the `TRACT_LLC_BYTES` override.
154#[cfg(any(target_os = "linux", target_os = "android"))]
155fn system_level_cache_bytes() -> usize {
156    use std::path::Path;
157    fn be_u32(p: &Path) -> Option<u32> {
158        let b = std::fs::read(p).ok()?;
159        (b.len() >= 4).then(|| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
160    }
161    fn walk(dir: &Path, depth: usize, best: &mut usize) {
162        if depth == 0 {
163            return;
164        }
165        if be_u32(&dir.join("cache-level")) == Some(3) {
166            let sz = be_u32(&dir.join("cache-size")).unwrap_or(0) as usize;
167            *best = (*best).max(sz);
168        }
169        let Ok(rd) = std::fs::read_dir(dir) else { return };
170        for e in rd.flatten() {
171            let p = e.path();
172            // CPU caches are handled by the architectural L3 probe; skip them.
173            if p.is_dir() && p.file_name().and_then(|n| n.to_str()) != Some("cpus") {
174                walk(&p, depth - 1, best);
175            }
176        }
177    }
178    let mut best = 0;
179    for root in ["/proc/device-tree", "/sys/firmware/devicetree/base"] {
180        let p = Path::new(root);
181        if p.exists() {
182            walk(p, 4, &mut best);
183            if best > 0 {
184                break;
185            }
186        }
187    }
188    best
189}
190
191#[cfg(not(any(target_os = "linux", target_os = "android")))]
192fn system_level_cache_bytes() -> usize {
193    0
194}
195
196/// Parse a Linux `/sys` cache `size` string (e.g. `"256K"`, `"8M"`, `"512"`).
197#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))]
198fn parse_cache_size(s: &str) -> usize {
199    let s = s.trim();
200    let (num, mult) = if let Some(n) = s.strip_suffix(['K', 'k']) {
201        (n, 1024)
202    } else if let Some(n) = s.strip_suffix(['M', 'm']) {
203        (n, 1024 * 1024)
204    } else {
205        (s, 1)
206    };
207    num.trim().parse::<usize>().unwrap_or(0) * mult
208}
209
210#[cfg(any(target_os = "macos", target_os = "ios"))]
211fn detect() -> CacheInfo {
212    // Read a scalar `hw.*` sysctl by name via the libc FFI (no subprocess).
213    // macOS returns these as a little-endian integer (4 or 8 bytes); a zeroed
214    // 8-byte buffer reads either width correctly on little-endian Apple silicon
215    // and Intel.
216    fn sysctl_usize(name: &str) -> Option<usize> {
217        use std::ffi::CString;
218        use std::os::raw::{c_char, c_int, c_void};
219        unsafe extern "C" {
220            fn sysctlbyname(
221                name: *const c_char,
222                oldp: *mut c_void,
223                oldlenp: *mut usize,
224                newp: *mut c_void,
225                newlen: usize,
226            ) -> c_int;
227        }
228        let cname = CString::new(name).ok()?;
229        let mut val: u64 = 0;
230        let mut len = std::mem::size_of::<u64>();
231        let rc = unsafe {
232            sysctlbyname(
233                cname.as_ptr(),
234                &mut val as *mut u64 as *mut c_void,
235                &mut len,
236                std::ptr::null_mut(),
237                0,
238            )
239        };
240        if rc != 0 || val == 0 { None } else { Some(val as usize) }
241    }
242
243    CacheInfo {
244        // perflevel0 is the performance cluster on hybrid Apple Silicon.
245        l1_data: sysctl_usize("hw.perflevel0.l1dcachesize")
246            .or_else(|| sysctl_usize("hw.l1dcachesize"))
247            .unwrap_or(0),
248        l2: sysctl_usize("hw.perflevel0.l2cachesize")
249            .or_else(|| sysctl_usize("hw.l2cachesize"))
250            .unwrap_or(0),
251        l3: sysctl_usize("hw.perflevel0.l3cachesize")
252            .or_else(|| sysctl_usize("hw.l3cachesize"))
253            .unwrap_or(0),
254        // Apple L2 is per-cluster (shared across a perflevel's cores), but sysctl
255        // does not expose the sharing degree; report unknown (treated as private).
256        l2_sharers: 0,
257    }
258}
259
260/// Count the CPUs named by a Linux cpu-list string (`"0-3"`, `"0,8"`,
261/// `"0-3,8-11"`). Malformed fields are skipped, so a garbled file counts 0.
262#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))]
263fn count_cpu_list(s: &str) -> usize {
264    s.split(',')
265        .filter_map(|part| {
266            let part = part.trim();
267            if part.is_empty() {
268                return None;
269            }
270            match part.split_once('-') {
271                Some((a, b)) => {
272                    let a: usize = a.trim().parse().ok()?;
273                    let b: usize = b.trim().parse().ok()?;
274                    (b >= a).then_some(b - a + 1)
275                }
276                None => part.parse::<usize>().ok().map(|_| 1),
277            }
278        })
279        .sum()
280}
281
282#[cfg(any(target_os = "linux", target_os = "android"))]
283fn detect() -> CacheInfo {
284    // Walk /sys/.../cache/indexN, keying off the reported level+type rather than
285    // assuming a fixed index layout (it varies: SMT, unified vs split L2, …).
286    let read = |p: String| std::fs::read_to_string(p).ok();
287    let mut ci = CacheInfo::default();
288    // SMT siblings share the core's L2 already; only cores beyond that set count
289    // as L2-sharing. Absent topology ⇒ assume no SMT (1).
290    let smt = read("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list".to_string())
291        .map(|s| count_cpu_list(&s))
292        .filter(|&n| n > 0)
293        .unwrap_or(1);
294    for idx in 0..16 {
295        let base = format!("/sys/devices/system/cpu/cpu0/cache/index{idx}/");
296        let Some(level) = read(format!("{base}level")) else {
297            continue;
298        };
299        let level: usize = level.trim().parse().unwrap_or(0);
300        let ctype = read(format!("{base}type")).unwrap_or_default().trim().to_ascii_lowercase();
301        let size = read(format!("{base}size")).map(|s| parse_cache_size(&s)).unwrap_or(0);
302        if size == 0 {
303            continue;
304        }
305        match level {
306            1 if ctype == "data" || ctype == "unified" => {
307                if ci.l1_data == 0 {
308                    ci.l1_data = size;
309                }
310            }
311            2 if ci.l2 == 0 => {
312                ci.l2 = size;
313                let cpus =
314                    read(format!("{base}shared_cpu_list")).map(|s| count_cpu_list(&s)).unwrap_or(0);
315                ci.l2_sharers = (cpus / smt).max(1);
316            }
317            3 if ci.l3 == 0 => ci.l3 = size,
318            _ => {}
319        }
320    }
321    ci
322}
323
324#[cfg(target_os = "windows")]
325fn detect() -> CacheInfo {
326    // wmic only reports L2/L3 (in KiB) and is deprecated on Win11; it is the
327    // dependency-free option. L1 is left unknown (→ l1_data_or_default).
328    // A future GetLogicalProcessorInformationEx probe would also yield L1.
329    let mut ci = CacheInfo::default();
330    if let Ok(out) = std::process::Command::new("wmic")
331        .args(["cpu", "get", "L2CacheSize,L3CacheSize", "/format:value"])
332        .output()
333    {
334        for line in String::from_utf8_lossy(&out.stdout).lines() {
335            let line = line.trim();
336            if let Some(v) = line.strip_prefix("L2CacheSize=") {
337                if let Ok(kb) = v.trim().parse::<usize>() {
338                    ci.l2 = kb * 1024;
339                }
340            } else if let Some(v) = line.strip_prefix("L3CacheSize=") {
341                if let Ok(kb) = v.trim().parse::<usize>() {
342                    ci.l3 = kb * 1024;
343                }
344            }
345        }
346    }
347    ci
348}
349
350#[cfg(not(any(
351    target_os = "macos",
352    target_os = "ios",
353    target_os = "linux",
354    target_os = "android",
355    target_os = "windows"
356)))]
357fn detect() -> CacheInfo {
358    // WASM, BSDs, etc.: no portable probe — report unknown, callers fall back.
359    CacheInfo::default()
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn llc_resolution_priority() {
368        // override wins over everything; contended flag selects the kind.
369        assert_eq!(
370            resolve_llc(Some(8 << 20), false, 1 << 20, 4 << 20, 0),
371            Some((8 << 20, LlcKind::Dedicated))
372        );
373        assert_eq!(
374            resolve_llc(Some(8 << 20), true, 1 << 20, 0, 0),
375            Some((8 << 20, LlcKind::SystemLevel))
376        );
377        // no override: architectural L3 (> L2) is Dedicated.
378        assert_eq!(
379            resolve_llc(None, false, 1 << 20, 4 << 20, 0),
380            Some((4 << 20, LlcKind::Dedicated))
381        );
382        // no L3, but an SLC > L2 is reported: SystemLevel (contended).
383        assert_eq!(
384            resolve_llc(None, false, 512 << 10, 0, 4 << 20),
385            Some((4 << 20, LlcKind::SystemLevel))
386        );
387        // nothing larger than L2 known ⇒ no outer tier (regression-safe).
388        assert_eq!(resolve_llc(None, false, 1 << 20, 0, 0), None);
389        assert_eq!(resolve_llc(None, false, 1 << 20, 1 << 20, 512 << 10), None);
390        // a zero/garbage override is ignored, falling through to detection.
391        assert_eq!(
392            resolve_llc(Some(0), false, 1 << 20, 4 << 20, 0),
393            Some((4 << 20, LlcKind::Dedicated))
394        );
395    }
396
397    #[test]
398    fn slc_probe_never_panics() {
399        // On the test host this is typically 0 (no devicetree SLC); just exercise it.
400        let _ = system_level_cache_bytes();
401        let _ = last_level_cache();
402    }
403
404    #[test]
405    fn parse_cache_size_units() {
406        assert_eq!(parse_cache_size("512"), 512);
407        assert_eq!(parse_cache_size("256K"), 256 * 1024);
408        assert_eq!(parse_cache_size("8M"), 8 * 1024 * 1024);
409        assert_eq!(parse_cache_size(" 1024k "), 1024 * 1024);
410        assert_eq!(parse_cache_size("garbage"), 0);
411    }
412
413    #[test]
414    fn cpu_list_counts() {
415        assert_eq!(count_cpu_list("0"), 1);
416        assert_eq!(count_cpu_list("0-15"), 16);
417        assert_eq!(count_cpu_list("0,8"), 2);
418        assert_eq!(count_cpu_list("0-3,8-11"), 8);
419        assert_eq!(count_cpu_list(""), 0);
420        assert_eq!(count_cpu_list("garbage"), 0);
421    }
422
423    #[test]
424    fn defaults_are_nonzero() {
425        let unknown = CacheInfo::default();
426        assert!(unknown.l1_data_or_default() >= 32 * 1024);
427        assert_eq!(unknown.l2_or_default(), 256 * 1024);
428    }
429
430    #[test]
431    fn detected_values_are_sane_when_present() {
432        // Detection must never panic and must be self-consistent: any level it
433        // *does* report should be a plausible power-of-two-ish cache size, and
434        // L1 <= L2 <= L3 when all are known.
435        let ci = cache_info();
436        for (name, v) in [("l1d", ci.l1_data), ("l2", ci.l2), ("l3", ci.l3)] {
437            assert!(v == 0 || (1024..=512 * 1024 * 1024).contains(&v), "{name} implausible: {v}");
438        }
439        if ci.l1_data > 0 && ci.l2 > 0 {
440            assert!(ci.l1_data <= ci.l2, "L1 {} > L2 {}", ci.l1_data, ci.l2);
441        }
442        if ci.l2 > 0 && ci.l3 > 0 {
443            assert!(ci.l2 <= ci.l3, "L2 {} > L3 {}", ci.l2, ci.l3);
444        }
445    }
446}