Skip to main content

rlx_runtime/
hwinfo.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//! Hardware introspection (plan #47).
6//!
7//! Exposes CPU / GPU details and current thermal state. The
8//! autotuner / calibrator uses [`HwSnapshot::fingerprint`] to key
9//! its cache so calibration data is invalidated when the user moves
10//! a workspace between machines (or even when the OS re-classifies
11//! cores after a SoC update).
12//!
13//! Three layers:
14//! - **CPU** — [`HwSnapshot::collect`]: core topology, caches, and the
15//!   Apple thermal state. Read-only; no shell-out beyond `pmset -g therm`
16//!   (also invoked from `scripts/check-throttle.sh`).
17//! - **GPU telemetry** — [`device_thermal`] / [`all_gpu_thermal`]: live
18//!   temperature / power / clock / fan across the CUDA and ROCm backends
19//!   via the `rlx_cuda::nvml` (NVML) and `rlx_rocm::rsmi` (ROCm-SMI)
20//!   shims. Unprivileged; missing sensors read as `None`, never a fake 0.
21//! - **GPU control** — [`set_power_cap`] / [`set_locked_clocks`] /
22//!   [`set_fan_percent`] (and resets): root-only knobs returning a typed
23//!   [`ThermalError`], range-validated so a caller can't exceed the
24//!   device's safe envelope.
25
26use std::collections::hash_map::DefaultHasher;
27use std::hash::{Hash, Hasher};
28#[cfg(target_os = "macos")]
29use std::process::Command;
30
31use rlx_driver::Device;
32
33/// Coarse thermal state. Apple Silicon reports CPU speed limit and
34/// scheduler limit via `pmset -g therm`; both are 100 when nominal,
35/// less when throttling.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum ThermalState {
38    Nominal,
39    Throttled { cpu_speed_pct: u32, sched_pct: u32 },
40    Unknown,
41}
42
43#[derive(Debug, Clone)]
44pub struct HwSnapshot {
45    pub os: &'static str,
46    pub arch: &'static str,
47    pub cpu_brand: String,
48    /// Total logical CPUs (including E-cores on Apple Silicon).
49    pub total_cpus: usize,
50    /// Performance cores. 0 if unknown / not asymmetric.
51    pub perf_cores: usize,
52    /// L1 data cache bytes per core (0 if unknown).
53    pub l1d_bytes: usize,
54    /// L2 cache bytes per cluster (0 if unknown).
55    pub l2_bytes: usize,
56    /// Cache line size from the OS.
57    pub cache_line: usize,
58    pub thermal: ThermalState,
59}
60
61impl HwSnapshot {
62    /// Read all the queryable hardware details. Cheap (~ms); call
63    /// per-process at startup or whenever you need a fresh thermal
64    /// reading.
65    pub fn collect() -> Self {
66        let total_cpus = std::thread::available_parallelism()
67            .map(|n| n.get())
68            .unwrap_or(2);
69
70        // Mutated only under per-OS cfg blocks below (currently macOS).
71        #[allow(unused_mut)]
72        let mut snap = Self {
73            os: std::env::consts::OS,
74            arch: std::env::consts::ARCH,
75            cpu_brand: String::new(),
76            total_cpus,
77            perf_cores: 0,
78            l1d_bytes: 0,
79            l2_bytes: 0,
80            cache_line: 0,
81            thermal: ThermalState::Unknown,
82        };
83
84        // sysctl is available on every Apple platform; the `hw.*` keys are
85        // readable from the iOS sandbox too (failures degrade to 0 / "").
86        #[cfg(target_vendor = "apple")]
87        {
88            snap.cpu_brand = sysctl_str("machdep.cpu.brand_string").unwrap_or_default();
89            snap.perf_cores = sysctl_usize("hw.perflevel0.physicalcpu").unwrap_or(0);
90            snap.l1d_bytes = sysctl_usize("hw.l1dcachesize").unwrap_or(0);
91            snap.l2_bytes = sysctl_usize("hw.l2cachesize").unwrap_or(0);
92            snap.cache_line = sysctl_usize("hw.cachelinesize").unwrap_or(0);
93        }
94        // `pmset` is a macOS CLI; the iOS sandbox forbids spawning processes,
95        // so iOS leaves `thermal` at `Unknown`.
96        #[cfg(target_os = "macos")]
97        {
98            snap.thermal = read_pmset_thermal().unwrap_or(ThermalState::Unknown);
99        }
100
101        snap
102    }
103
104    /// Stable hash of the *machine* fields (everything except
105    /// thermal state). Suitable as a calibration cache key — same
106    /// machine returns the same fingerprint across boots.
107    pub fn fingerprint(&self) -> u64 {
108        let mut h = DefaultHasher::new();
109        self.os.hash(&mut h);
110        self.arch.hash(&mut h);
111        self.cpu_brand.hash(&mut h);
112        self.total_cpus.hash(&mut h);
113        self.perf_cores.hash(&mut h);
114        self.l1d_bytes.hash(&mut h);
115        self.l2_bytes.hash(&mut h);
116        self.cache_line.hash(&mut h);
117        h.finish()
118    }
119
120    /// Convenience: is the machine currently throttling?
121    pub fn is_throttled(&self) -> bool {
122        matches!(self.thermal, ThermalState::Throttled { .. })
123    }
124}
125
126#[cfg(target_vendor = "apple")]
127fn sysctl_usize(name: &str) -> Option<usize> {
128    use std::ffi::CString;
129    let cname = CString::new(name).ok()?;
130    let mut val: u64 = 0;
131    let mut len = std::mem::size_of::<u64>();
132    unsafe extern "C" {
133        fn sysctlbyname(
134            name: *const std::os::raw::c_char,
135            oldp: *mut std::os::raw::c_void,
136            oldlenp: *mut usize,
137            newp: *mut std::os::raw::c_void,
138            newlen: usize,
139        ) -> std::os::raw::c_int;
140    }
141    let rc = unsafe {
142        sysctlbyname(
143            cname.as_ptr(),
144            &mut val as *mut u64 as *mut _,
145            &mut len,
146            std::ptr::null_mut(),
147            0,
148        )
149    };
150    if rc == 0 { Some(val as usize) } else { None }
151}
152
153#[cfg(target_vendor = "apple")]
154fn sysctl_str(name: &str) -> Option<String> {
155    use std::ffi::CString;
156    let cname = CString::new(name).ok()?;
157    unsafe extern "C" {
158        fn sysctlbyname(
159            name: *const std::os::raw::c_char,
160            oldp: *mut std::os::raw::c_void,
161            oldlenp: *mut usize,
162            newp: *mut std::os::raw::c_void,
163            newlen: usize,
164        ) -> std::os::raw::c_int;
165    }
166    // First call: query buffer length.
167    let mut len: usize = 0;
168    let rc = unsafe {
169        sysctlbyname(
170            cname.as_ptr(),
171            std::ptr::null_mut(),
172            &mut len,
173            std::ptr::null_mut(),
174            0,
175        )
176    };
177    if rc != 0 || len == 0 {
178        return None;
179    }
180    let mut buf = vec![0u8; len];
181    let rc = unsafe {
182        sysctlbyname(
183            cname.as_ptr(),
184            buf.as_mut_ptr() as *mut _,
185            &mut len,
186            std::ptr::null_mut(),
187            0,
188        )
189    };
190    if rc != 0 {
191        return None;
192    }
193    // Strip trailing NUL.
194    if let Some(&0) = buf.last() {
195        buf.pop();
196    }
197    String::from_utf8(buf).ok()
198}
199
200#[cfg(target_os = "macos")]
201fn read_pmset_thermal() -> Option<ThermalState> {
202    let out = Command::new("pmset").args(["-g", "therm"]).output().ok()?;
203    if !out.status.success() {
204        return None;
205    }
206    let s = String::from_utf8_lossy(&out.stdout);
207    let mut cpu_speed = 100u32;
208    let mut sched = 100u32;
209    for line in s.lines() {
210        if let Some(rest) = line.split('=').nth(1) {
211            let val = rest.trim().parse::<u32>().ok();
212            if line.contains("CPU_Speed_Limit") {
213                if let Some(v) = val {
214                    cpu_speed = v;
215                }
216            } else if line.contains("CPU_Scheduler_Limit")
217                && let Some(v) = val
218            {
219                sched = v;
220            }
221        }
222    }
223    Some(if cpu_speed < 100 || sched < 100 {
224        ThermalState::Throttled {
225            cpu_speed_pct: cpu_speed,
226            sched_pct: sched,
227        }
228    } else {
229        ThermalState::Nominal
230    })
231}
232
233#[cfg(not(target_os = "macos"))]
234#[allow(dead_code)]
235fn read_pmset_thermal() -> Option<ThermalState> {
236    None
237}
238
239// ── Live GPU telemetry ───────────────────────────────────────────────
240//
241// Cross-backend, read-only, unprivileged GPU temperature / power / clock
242// sampling. The concrete per-vendor readers live inside their backend
243// crates as self-contained libloading shims (`rlx_cuda::nvml`,
244// `rlx_rocm::rsmi`), exactly like `is_available` dispatches into each
245// backend behind its Cargo feature. This keeps `Device` the only thing
246// that knows the vendor mapping and adds no new cross-crate deps.
247//
248// Control (set power cap / lock clocks / fan) is deliberately absent: it
249// needs root and is a separate follow-up.
250
251/// A live, read-only GPU telemetry sample, normalised to friendly units.
252///
253/// Every reading is `Option` because boards differ in what they expose —
254/// a discrete card (MI100, desktop RTX) reports a junction/hotspot sensor
255/// and a settable power cap, while a laptop GPU or APU/iGPU omits the fan
256/// reading, the junction sensor, or the cap. A `None` means "this board /
257/// driver does not report it", never a fabricated zero.
258#[derive(Debug, Clone, PartialEq)]
259pub struct GpuThermal {
260    /// rlx backend family this sample came through (`Cuda` / `Rocm`).
261    pub device: Device,
262    /// Hardware ordinal within that backend (NVML index / rocm_smi dv_ind).
263    pub index: u32,
264    /// Board product name, if the driver reports one.
265    pub name: Option<String>,
266    /// Primary GPU-die / edge temperature, °C.
267    pub temp_c: Option<f32>,
268    /// Junction / hotspot temperature, °C (discrete AMD; `None` on NVIDIA/iGPU).
269    pub hotspot_c: Option<f32>,
270    /// VRAM temperature, °C (discrete cards only).
271    pub mem_temp_c: Option<f32>,
272    /// Instantaneous board power draw, W.
273    pub power_w: Option<f32>,
274    /// Currently enforced / configured power cap, W.
275    pub power_cap_w: Option<f32>,
276    /// Fan duty cycle, %.
277    pub fan_percent: Option<f32>,
278    /// Core/SM clock, MHz (NVIDIA SM clock; `None` on ROCm for now).
279    pub clock_mhz: Option<u32>,
280    /// GPU utilization, %.
281    pub util_percent: Option<f32>,
282}
283
284impl GpuThermal {
285    /// How close the board is to its power cap, in `[0, 1]` — a proxy for
286    /// "am I power-limited right now". `None` when either reading is absent.
287    pub fn power_headroom(&self) -> Option<f32> {
288        match (self.power_w, self.power_cap_w) {
289            (Some(p), Some(cap)) if cap > 0.0 => Some((p / cap).clamp(0.0, 1.0)),
290            _ => None,
291        }
292    }
293}
294
295/// Number of monitorable GPUs for `device`'s backend. `0` when the
296/// backend feature is disabled or its management library is absent.
297pub fn device_thermal_count(device: Device) -> usize {
298    match device {
299        #[cfg(feature = "cuda")]
300        Device::Cuda => rlx_cuda::nvml::device_count(),
301        #[cfg(feature = "rocm")]
302        Device::Rocm => rlx_rocm::rsmi::device_count(),
303        _ => {
304            let _ = device;
305            0
306        }
307    }
308}
309
310/// Read a one-shot telemetry sample for GPU `index` of `device`'s
311/// backend. Returns `None` when the backend feature is off, the
312/// management library is missing, or the index is out of range.
313/// Read-only and unprivileged — safe to call from any thread at any time.
314pub fn device_thermal(device: Device, index: u32) -> Option<GpuThermal> {
315    match device {
316        #[cfg(feature = "cuda")]
317        Device::Cuda => {
318            let s = rlx_cuda::nvml::sample(index)?;
319            Some(GpuThermal {
320                device,
321                index,
322                name: s.name,
323                temp_c: s.temp_c,
324                hotspot_c: None,
325                mem_temp_c: None,
326                power_w: s.power_w,
327                power_cap_w: s.power_cap_w,
328                fan_percent: s.fan_percent,
329                clock_mhz: s.sm_clock_mhz,
330                util_percent: s.util_percent,
331            })
332        }
333        #[cfg(feature = "rocm")]
334        Device::Rocm => {
335            let s = rlx_rocm::rsmi::sample(index)?;
336            Some(GpuThermal {
337                device,
338                index,
339                name: s.name,
340                temp_c: s.temp_edge_c,
341                hotspot_c: s.temp_hotspot_c,
342                mem_temp_c: s.temp_mem_c,
343                power_w: s.power_w,
344                power_cap_w: s.power_cap_w,
345                fan_percent: s.fan_percent,
346                clock_mhz: None,
347                util_percent: s.util_percent,
348            })
349        }
350        _ => {
351            let _ = (device, index);
352            None
353        }
354    }
355}
356
357/// Sample every monitorable GPU across all compiled-in GPU backends
358/// (CUDA + ROCm today). Empty when no GPU management library is present.
359pub fn all_gpu_thermal() -> Vec<GpuThermal> {
360    let mut out = Vec::new();
361    for &device in &[Device::Cuda, Device::Rocm] {
362        for index in 0..device_thermal_count(device) as u32 {
363            if let Some(s) = device_thermal(device, index) {
364                out.push(s);
365            }
366        }
367    }
368    out
369}
370
371// ── GPU control (privileged) ─────────────────────────────────────────
372//
373// Root-only knobs: set/reset power cap, locked clocks (NVIDIA), and fan.
374// Reads above are unprivileged; these are not. The concrete drivers live
375// in the same backend shims and return raw status codes; the runtime maps
376// them to a typed `ThermalError` and pre-validates ranges so a caller
377// can't drive a GPU outside its safe envelope.
378
379/// Why a GPU control operation didn't take effect.
380#[derive(Debug, Clone, PartialEq)]
381pub enum ThermalError {
382    /// Backend feature disabled, management library absent, or bad index.
383    Unavailable,
384    /// This board / driver doesn't expose the knob (e.g. laptop power-cap,
385    /// ROCm clock-lock, a server GPU with no controllable fan).
386    Unsupported,
387    /// The operation needs elevated privileges — run as root / `sudo`.
388    PermissionDenied,
389    /// Requested value is outside the device's valid range.
390    OutOfRange { requested: f32, min: f32, max: f32 },
391    /// Driver returned a status we don't specifically classify.
392    Driver(i32),
393}
394
395impl std::fmt::Display for ThermalError {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        match self {
398            ThermalError::Unavailable => write!(
399                f,
400                "device unavailable (backend feature off or management library absent)"
401            ),
402            ThermalError::Unsupported => write!(f, "not supported on this GPU/driver"),
403            ThermalError::PermissionDenied => write!(f, "permission denied — run as root (sudo)"),
404            ThermalError::OutOfRange {
405                requested,
406                min,
407                max,
408            } => write!(
409                f,
410                "requested {requested:.0} outside valid range {min:.0}..={max:.0}"
411            ),
412            ThermalError::Driver(n) => write!(f, "driver error (status {n})"),
413        }
414    }
415}
416
417impl std::error::Error for ThermalError {}
418
419// Raw driver status → ThermalError. `-1`/`-2` are the shims' sentinels
420// (unavailable / symbol-missing); the positive codes are vendor-specific.
421#[cfg(feature = "cuda")]
422fn map_nvml(rc: i32) -> ThermalError {
423    match rc {
424        -1 => ThermalError::Unavailable,
425        -2 | 3 => ThermalError::Unsupported, // NVML_ERROR_NOT_SUPPORTED = 3
426        4 => ThermalError::PermissionDenied, // NVML_ERROR_NO_PERMISSION = 4
427        n => ThermalError::Driver(n),
428    }
429}
430
431#[cfg(feature = "rocm")]
432fn map_rsmi(rc: i32) -> ThermalError {
433    match rc {
434        -1 => ThermalError::Unavailable,
435        // RSMI_STATUS_NOT_SUPPORTED = 2, NOT_YET_IMPLEMENTED = 9
436        -2 | 2 | 9 => ThermalError::Unsupported,
437        4 => ThermalError::PermissionDenied, // RSMI_STATUS_PERMISSION = 4
438        n => ThermalError::Driver(n),
439    }
440}
441
442/// Valid power-cap range `(min_w, max_w)` for `device`'s GPU `index`.
443/// Read-only; `None` when unavailable. Use it to bound a [`set_power_cap`].
444pub fn power_cap_range(device: Device, index: u32) -> Option<(f32, f32)> {
445    match device {
446        #[cfg(feature = "cuda")]
447        Device::Cuda => rlx_cuda::nvml::power_cap_range(index),
448        #[cfg(feature = "rocm")]
449        Device::Rocm => rlx_rocm::rsmi::power_cap_range(index),
450        _ => {
451            let _ = (device, index);
452            None
453        }
454    }
455}
456
457/// Set the sustained power cap (watts) for `device`'s GPU `index`.
458///
459/// Requires root. Rejected with [`ThermalError::OutOfRange`] if outside the
460/// device's [`power_cap_range`], [`ThermalError::Unsupported`] on boards
461/// that don't allow it (many laptop GPUs — use [`set_locked_clocks`]
462/// there), or [`ThermalError::PermissionDenied`] without privileges.
463pub fn set_power_cap(device: Device, index: u32, watts: f32) -> Result<(), ThermalError> {
464    if let Some((min, max)) = power_cap_range(device, index) {
465        // ±0.5 W tolerance so a boundary value isn't rejected on rounding.
466        if watts + 0.5 < min || watts - 0.5 > max {
467            return Err(ThermalError::OutOfRange {
468                requested: watts,
469                min,
470                max,
471            });
472        }
473    }
474    match device {
475        #[cfg(feature = "cuda")]
476        Device::Cuda => rlx_cuda::nvml::set_power_cap(index, watts).map_err(map_nvml),
477        #[cfg(feature = "rocm")]
478        Device::Rocm => rlx_rocm::rsmi::set_power_cap(index, watts).map_err(map_rsmi),
479        _ => {
480            let _ = (device, index, watts);
481            Err(ThermalError::Unsupported)
482        }
483    }
484}
485
486/// Pin `device`'s GPU `index` core clocks into `[min_mhz, max_mhz]`.
487///
488/// NVIDIA only (the effective lever on laptop parts that reject a power
489/// cap). ROCm returns [`ThermalError::Unsupported`] — its clock control
490/// needs perf-level=MANUAL + a frequency bitmask; use [`set_power_cap`]
491/// on the MI100 instead. Requires root.
492pub fn set_locked_clocks(
493    device: Device,
494    index: u32,
495    min_mhz: u32,
496    max_mhz: u32,
497) -> Result<(), ThermalError> {
498    let _ = (index, min_mhz, max_mhz);
499    match device {
500        #[cfg(feature = "cuda")]
501        Device::Cuda => {
502            rlx_cuda::nvml::set_locked_clocks(index, min_mhz, max_mhz).map_err(map_nvml)
503        }
504        _ => Err(ThermalError::Unsupported),
505    }
506}
507
508/// Release a previous [`set_locked_clocks`] (back to automatic). NVIDIA
509/// only; requires root.
510pub fn reset_locked_clocks(device: Device, index: u32) -> Result<(), ThermalError> {
511    let _ = index;
512    match device {
513        #[cfg(feature = "cuda")]
514        Device::Cuda => rlx_cuda::nvml::reset_locked_clocks(index).map_err(map_nvml),
515        _ => Err(ThermalError::Unsupported),
516    }
517}
518
519/// Set fan duty (%) on `device`'s GPU `index`; enters manual fan mode
520/// until [`reset_fan`]. Requires root; `Unsupported` on GPUs with no
521/// controllable fan (many servers / laptops).
522pub fn set_fan_percent(device: Device, index: u32, percent: f32) -> Result<(), ThermalError> {
523    match device {
524        #[cfg(feature = "cuda")]
525        Device::Cuda => rlx_cuda::nvml::set_fan_percent(index, percent).map_err(map_nvml),
526        #[cfg(feature = "rocm")]
527        Device::Rocm => rlx_rocm::rsmi::set_fan_percent(index, percent).map_err(map_rsmi),
528        _ => {
529            let _ = (device, index, percent);
530            Err(ThermalError::Unsupported)
531        }
532    }
533}
534
535/// Return `device`'s GPU `index` fan to automatic control. Requires root.
536pub fn reset_fan(device: Device, index: u32) -> Result<(), ThermalError> {
537    match device {
538        #[cfg(feature = "cuda")]
539        Device::Cuda => rlx_cuda::nvml::reset_fan(index).map_err(map_nvml),
540        #[cfg(feature = "rocm")]
541        Device::Rocm => rlx_rocm::rsmi::reset_fan(index).map_err(map_rsmi),
542        _ => {
543            let _ = (device, index);
544            Err(ThermalError::Unsupported)
545        }
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn gpu_thermal_never_panics() {
555        // Must be graceful on hosts with no NVIDIA/AMD management lib
556        // (Mac/CI): empty inventory, no reads, no panic.
557        let all = all_gpu_thermal();
558        for s in &all {
559            // If we got a sample, the ordinal must be addressable again.
560            assert!(device_thermal(s.device, s.index).is_some());
561        }
562        // Counts are non-negative by type; calling is enough to exercise
563        // the dispatch under whatever features are enabled.
564        let _ = device_thermal_count(Device::Cuda);
565        let _ = device_thermal_count(Device::Rocm);
566    }
567
568    #[test]
569    fn control_is_graceful_without_hardware() {
570        // On a host without the management lib, every control call must
571        // return a typed error (never panic). CPU/GPU-family devices with
572        // no thermal backend → Unsupported; the specific vendor error only
573        // shows up on real hardware.
574        for &d in &[Device::Cpu, Device::Metal, Device::Cuda, Device::Rocm] {
575            let _ = power_cap_range(d, 0);
576            assert!(set_power_cap(d, 0, 100.0).is_err() || crate::is_available(d));
577            assert!(set_fan_percent(d, 0, 50.0).is_err() || crate::is_available(d));
578            let _ = reset_fan(d, 0);
579            let _ = reset_locked_clocks(d, 0);
580        }
581        // ROCm clock-lock is intentionally unsupported everywhere.
582        assert_eq!(
583            set_locked_clocks(Device::Rocm, 0, 1000, 1000),
584            Err(ThermalError::Unsupported)
585        );
586    }
587
588    #[test]
589    fn thermal_error_display_is_actionable() {
590        assert!(ThermalError::PermissionDenied.to_string().contains("root"));
591        let oor = ThermalError::OutOfRange {
592            requested: 400.0,
593            min: 100.0,
594            max: 290.0,
595        };
596        let s = oor.to_string();
597        assert!(s.contains("400") && s.contains("290"));
598    }
599
600    #[test]
601    fn snapshot_doesnt_panic() {
602        let snap = HwSnapshot::collect();
603        // The OS / arch fields are always set.
604        assert!(!snap.os.is_empty());
605        assert!(!snap.arch.is_empty());
606    }
607
608    #[test]
609    fn fingerprint_is_stable_across_collects() {
610        // Two collects on the same machine must agree on fingerprint
611        // (thermal state is excluded).
612        let a = HwSnapshot::collect();
613        let b = HwSnapshot::collect();
614        assert_eq!(a.fingerprint(), b.fingerprint());
615    }
616}