Skip to main content

rlx_runtime/
hwinfo.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//! Hardware introspection (plan #47).
17//!
18//! Apple-side equivalent of MAX's `nvml.mojo` / `device_query.mojo`.
19//! Exposes CPU / GPU details and current thermal state. The
20//! autotuner / calibrator uses [`HwSnapshot::fingerprint`] to key
21//! its cache so calibration data is invalidated when the user moves
22//! a workspace between Macs (or even when the OS re-classifies
23//! cores after a SoC update).
24//!
25//! Pure read-only queries; no shell-out beyond `pmset -g therm`
26//! (which we already invoke from `scripts/check-throttle.sh`).
27
28use std::collections::hash_map::DefaultHasher;
29use std::hash::{Hash, Hasher};
30use std::process::Command;
31
32/// Coarse thermal state. Apple Silicon reports CPU speed limit and
33/// scheduler limit via `pmset -g therm`; both are 100 when nominal,
34/// less when throttling.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum ThermalState {
37    Nominal,
38    Throttled { cpu_speed_pct: u32, sched_pct: u32 },
39    Unknown,
40}
41
42#[derive(Debug, Clone)]
43pub struct HwSnapshot {
44    pub os: &'static str,
45    pub arch: &'static str,
46    pub cpu_brand: String,
47    /// Total logical CPUs (including E-cores on Apple Silicon).
48    pub total_cpus: usize,
49    /// Performance cores. 0 if unknown / not asymmetric.
50    pub perf_cores: usize,
51    /// L1 data cache bytes per core (0 if unknown).
52    pub l1d_bytes: usize,
53    /// L2 cache bytes per cluster (0 if unknown).
54    pub l2_bytes: usize,
55    /// Cache line size from the OS.
56    pub cache_line: usize,
57    pub thermal: ThermalState,
58}
59
60impl HwSnapshot {
61    /// Read all the queryable hardware details. Cheap (~ms); call
62    /// per-process at startup or whenever you need a fresh thermal
63    /// reading.
64    pub fn collect() -> Self {
65        let total_cpus = std::thread::available_parallelism()
66            .map(|n| n.get())
67            .unwrap_or(2);
68
69        let mut snap = Self {
70            os: std::env::consts::OS,
71            arch: std::env::consts::ARCH,
72            cpu_brand: String::new(),
73            total_cpus,
74            perf_cores: 0,
75            l1d_bytes: 0,
76            l2_bytes: 0,
77            cache_line: 0,
78            thermal: ThermalState::Unknown,
79        };
80
81        #[cfg(target_os = "macos")]
82        {
83            snap.cpu_brand = sysctl_str("machdep.cpu.brand_string").unwrap_or_default();
84            snap.perf_cores = sysctl_usize("hw.perflevel0.physicalcpu").unwrap_or(0);
85            snap.l1d_bytes = sysctl_usize("hw.l1dcachesize").unwrap_or(0);
86            snap.l2_bytes = sysctl_usize("hw.l2cachesize").unwrap_or(0);
87            snap.cache_line = sysctl_usize("hw.cachelinesize").unwrap_or(0);
88            snap.thermal = read_pmset_thermal().unwrap_or(ThermalState::Unknown);
89        }
90
91        snap
92    }
93
94    /// Stable hash of the *machine* fields (everything except
95    /// thermal state). Suitable as a calibration cache key — same
96    /// machine returns the same fingerprint across boots.
97    pub fn fingerprint(&self) -> u64 {
98        let mut h = DefaultHasher::new();
99        self.os.hash(&mut h);
100        self.arch.hash(&mut h);
101        self.cpu_brand.hash(&mut h);
102        self.total_cpus.hash(&mut h);
103        self.perf_cores.hash(&mut h);
104        self.l1d_bytes.hash(&mut h);
105        self.l2_bytes.hash(&mut h);
106        self.cache_line.hash(&mut h);
107        h.finish()
108    }
109
110    /// Convenience: is the machine currently throttling?
111    pub fn is_throttled(&self) -> bool {
112        matches!(self.thermal, ThermalState::Throttled { .. })
113    }
114}
115
116#[cfg(target_os = "macos")]
117fn sysctl_usize(name: &str) -> Option<usize> {
118    use std::ffi::CString;
119    let cname = CString::new(name).ok()?;
120    let mut val: u64 = 0;
121    let mut len = std::mem::size_of::<u64>();
122    unsafe extern "C" {
123        fn sysctlbyname(
124            name: *const std::os::raw::c_char,
125            oldp: *mut std::os::raw::c_void,
126            oldlenp: *mut usize,
127            newp: *mut std::os::raw::c_void,
128            newlen: usize,
129        ) -> std::os::raw::c_int;
130    }
131    let rc = unsafe {
132        sysctlbyname(
133            cname.as_ptr(),
134            &mut val as *mut u64 as *mut _,
135            &mut len,
136            std::ptr::null_mut(),
137            0,
138        )
139    };
140    if rc == 0 { Some(val as usize) } else { None }
141}
142
143#[cfg(target_os = "macos")]
144fn sysctl_str(name: &str) -> Option<String> {
145    use std::ffi::CString;
146    let cname = CString::new(name).ok()?;
147    unsafe extern "C" {
148        fn sysctlbyname(
149            name: *const std::os::raw::c_char,
150            oldp: *mut std::os::raw::c_void,
151            oldlenp: *mut usize,
152            newp: *mut std::os::raw::c_void,
153            newlen: usize,
154        ) -> std::os::raw::c_int;
155    }
156    // First call: query buffer length.
157    let mut len: usize = 0;
158    let rc = unsafe {
159        sysctlbyname(
160            cname.as_ptr(),
161            std::ptr::null_mut(),
162            &mut len,
163            std::ptr::null_mut(),
164            0,
165        )
166    };
167    if rc != 0 || len == 0 {
168        return None;
169    }
170    let mut buf = vec![0u8; len];
171    let rc = unsafe {
172        sysctlbyname(
173            cname.as_ptr(),
174            buf.as_mut_ptr() as *mut _,
175            &mut len,
176            std::ptr::null_mut(),
177            0,
178        )
179    };
180    if rc != 0 {
181        return None;
182    }
183    // Strip trailing NUL.
184    if let Some(&0) = buf.last() {
185        buf.pop();
186    }
187    String::from_utf8(buf).ok()
188}
189
190#[cfg(target_os = "macos")]
191fn read_pmset_thermal() -> Option<ThermalState> {
192    let out = Command::new("pmset").args(["-g", "therm"]).output().ok()?;
193    if !out.status.success() {
194        return None;
195    }
196    let s = String::from_utf8_lossy(&out.stdout);
197    let mut cpu_speed = 100u32;
198    let mut sched = 100u32;
199    for line in s.lines() {
200        if let Some(rest) = line.split('=').nth(1) {
201            let val = rest.trim().parse::<u32>().ok();
202            if line.contains("CPU_Speed_Limit") {
203                if let Some(v) = val {
204                    cpu_speed = v;
205                }
206            } else if line.contains("CPU_Scheduler_Limit")
207                && let Some(v) = val
208            {
209                sched = v;
210            }
211        }
212    }
213    Some(if cpu_speed < 100 || sched < 100 {
214        ThermalState::Throttled {
215            cpu_speed_pct: cpu_speed,
216            sched_pct: sched,
217        }
218    } else {
219        ThermalState::Nominal
220    })
221}
222
223#[cfg(not(target_os = "macos"))]
224#[allow(dead_code)]
225fn read_pmset_thermal() -> Option<ThermalState> {
226    None
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn snapshot_doesnt_panic() {
235        let snap = HwSnapshot::collect();
236        // The OS / arch fields are always set.
237        assert!(!snap.os.is_empty());
238        assert!(!snap.arch.is_empty());
239    }
240
241    #[test]
242    fn fingerprint_is_stable_across_collects() {
243        // Two collects on the same machine must agree on fingerprint
244        // (thermal state is excluded).
245        let a = HwSnapshot::collect();
246        let b = HwSnapshot::collect();
247        assert_eq!(a.fingerprint(), b.fingerprint());
248    }
249}