use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[cfg(target_os = "macos")]
use std::process::Command;
use rlx_driver::Device;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ThermalState {
Nominal,
Throttled { cpu_speed_pct: u32, sched_pct: u32 },
Unknown,
}
#[derive(Debug, Clone)]
pub struct HwSnapshot {
pub os: &'static str,
pub arch: &'static str,
pub cpu_brand: String,
pub total_cpus: usize,
pub perf_cores: usize,
pub l1d_bytes: usize,
pub l2_bytes: usize,
pub cache_line: usize,
pub thermal: ThermalState,
}
impl HwSnapshot {
pub fn collect() -> Self {
let total_cpus = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
#[allow(unused_mut)]
let mut snap = Self {
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
cpu_brand: String::new(),
total_cpus,
perf_cores: 0,
l1d_bytes: 0,
l2_bytes: 0,
cache_line: 0,
thermal: ThermalState::Unknown,
};
#[cfg(target_vendor = "apple")]
{
snap.cpu_brand = sysctl_str("machdep.cpu.brand_string").unwrap_or_default();
snap.perf_cores = sysctl_usize("hw.perflevel0.physicalcpu").unwrap_or(0);
snap.l1d_bytes = sysctl_usize("hw.l1dcachesize").unwrap_or(0);
snap.l2_bytes = sysctl_usize("hw.l2cachesize").unwrap_or(0);
snap.cache_line = sysctl_usize("hw.cachelinesize").unwrap_or(0);
}
#[cfg(target_os = "macos")]
{
snap.thermal = read_pmset_thermal().unwrap_or(ThermalState::Unknown);
}
snap
}
pub fn fingerprint(&self) -> u64 {
let mut h = DefaultHasher::new();
self.os.hash(&mut h);
self.arch.hash(&mut h);
self.cpu_brand.hash(&mut h);
self.total_cpus.hash(&mut h);
self.perf_cores.hash(&mut h);
self.l1d_bytes.hash(&mut h);
self.l2_bytes.hash(&mut h);
self.cache_line.hash(&mut h);
h.finish()
}
pub fn is_throttled(&self) -> bool {
matches!(self.thermal, ThermalState::Throttled { .. })
}
}
#[cfg(target_vendor = "apple")]
fn sysctl_usize(name: &str) -> Option<usize> {
use std::ffi::CString;
let cname = CString::new(name).ok()?;
let mut val: u64 = 0;
let mut len = std::mem::size_of::<u64>();
unsafe extern "C" {
fn sysctlbyname(
name: *const std::os::raw::c_char,
oldp: *mut std::os::raw::c_void,
oldlenp: *mut usize,
newp: *mut std::os::raw::c_void,
newlen: usize,
) -> std::os::raw::c_int;
}
let rc = unsafe {
sysctlbyname(
cname.as_ptr(),
&mut val as *mut u64 as *mut _,
&mut len,
std::ptr::null_mut(),
0,
)
};
if rc == 0 { Some(val as usize) } else { None }
}
#[cfg(target_vendor = "apple")]
fn sysctl_str(name: &str) -> Option<String> {
use std::ffi::CString;
let cname = CString::new(name).ok()?;
unsafe extern "C" {
fn sysctlbyname(
name: *const std::os::raw::c_char,
oldp: *mut std::os::raw::c_void,
oldlenp: *mut usize,
newp: *mut std::os::raw::c_void,
newlen: usize,
) -> std::os::raw::c_int;
}
let mut len: usize = 0;
let rc = unsafe {
sysctlbyname(
cname.as_ptr(),
std::ptr::null_mut(),
&mut len,
std::ptr::null_mut(),
0,
)
};
if rc != 0 || len == 0 {
return None;
}
let mut buf = vec![0u8; len];
let rc = unsafe {
sysctlbyname(
cname.as_ptr(),
buf.as_mut_ptr() as *mut _,
&mut len,
std::ptr::null_mut(),
0,
)
};
if rc != 0 {
return None;
}
if let Some(&0) = buf.last() {
buf.pop();
}
String::from_utf8(buf).ok()
}
#[cfg(target_os = "macos")]
fn read_pmset_thermal() -> Option<ThermalState> {
let out = Command::new("pmset").args(["-g", "therm"]).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout);
let mut cpu_speed = 100u32;
let mut sched = 100u32;
for line in s.lines() {
if let Some(rest) = line.split('=').nth(1) {
let val = rest.trim().parse::<u32>().ok();
if line.contains("CPU_Speed_Limit") {
if let Some(v) = val {
cpu_speed = v;
}
} else if line.contains("CPU_Scheduler_Limit")
&& let Some(v) = val
{
sched = v;
}
}
}
Some(if cpu_speed < 100 || sched < 100 {
ThermalState::Throttled {
cpu_speed_pct: cpu_speed,
sched_pct: sched,
}
} else {
ThermalState::Nominal
})
}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
fn read_pmset_thermal() -> Option<ThermalState> {
None
}
#[derive(Debug, Clone, PartialEq)]
pub struct GpuThermal {
pub device: Device,
pub index: u32,
pub name: Option<String>,
pub temp_c: Option<f32>,
pub hotspot_c: Option<f32>,
pub mem_temp_c: Option<f32>,
pub power_w: Option<f32>,
pub power_cap_w: Option<f32>,
pub fan_percent: Option<f32>,
pub clock_mhz: Option<u32>,
pub util_percent: Option<f32>,
}
impl GpuThermal {
pub fn power_headroom(&self) -> Option<f32> {
match (self.power_w, self.power_cap_w) {
(Some(p), Some(cap)) if cap > 0.0 => Some((p / cap).clamp(0.0, 1.0)),
_ => None,
}
}
}
pub fn device_thermal_count(device: Device) -> usize {
match device {
#[cfg(feature = "cuda")]
Device::Cuda => rlx_cuda::nvml::device_count(),
#[cfg(feature = "rocm")]
Device::Rocm => rlx_rocm::rsmi::device_count(),
_ => {
let _ = device;
0
}
}
}
pub fn device_thermal(device: Device, index: u32) -> Option<GpuThermal> {
match device {
#[cfg(feature = "cuda")]
Device::Cuda => {
let s = rlx_cuda::nvml::sample(index)?;
Some(GpuThermal {
device,
index,
name: s.name,
temp_c: s.temp_c,
hotspot_c: None,
mem_temp_c: None,
power_w: s.power_w,
power_cap_w: s.power_cap_w,
fan_percent: s.fan_percent,
clock_mhz: s.sm_clock_mhz,
util_percent: s.util_percent,
})
}
#[cfg(feature = "rocm")]
Device::Rocm => {
let s = rlx_rocm::rsmi::sample(index)?;
Some(GpuThermal {
device,
index,
name: s.name,
temp_c: s.temp_edge_c,
hotspot_c: s.temp_hotspot_c,
mem_temp_c: s.temp_mem_c,
power_w: s.power_w,
power_cap_w: s.power_cap_w,
fan_percent: s.fan_percent,
clock_mhz: None,
util_percent: s.util_percent,
})
}
_ => {
let _ = (device, index);
None
}
}
}
pub fn all_gpu_thermal() -> Vec<GpuThermal> {
let mut out = Vec::new();
for &device in &[Device::Cuda, Device::Rocm] {
for index in 0..device_thermal_count(device) as u32 {
if let Some(s) = device_thermal(device, index) {
out.push(s);
}
}
}
out
}
#[derive(Debug, Clone, PartialEq)]
pub enum ThermalError {
Unavailable,
Unsupported,
PermissionDenied,
OutOfRange { requested: f32, min: f32, max: f32 },
Driver(i32),
}
impl std::fmt::Display for ThermalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThermalError::Unavailable => write!(
f,
"device unavailable (backend feature off or management library absent)"
),
ThermalError::Unsupported => write!(f, "not supported on this GPU/driver"),
ThermalError::PermissionDenied => write!(f, "permission denied — run as root (sudo)"),
ThermalError::OutOfRange {
requested,
min,
max,
} => write!(
f,
"requested {requested:.0} outside valid range {min:.0}..={max:.0}"
),
ThermalError::Driver(n) => write!(f, "driver error (status {n})"),
}
}
}
impl std::error::Error for ThermalError {}
#[cfg(feature = "cuda")]
fn map_nvml(rc: i32) -> ThermalError {
match rc {
-1 => ThermalError::Unavailable,
-2 | 3 => ThermalError::Unsupported, 4 => ThermalError::PermissionDenied, n => ThermalError::Driver(n),
}
}
#[cfg(feature = "rocm")]
fn map_rsmi(rc: i32) -> ThermalError {
match rc {
-1 => ThermalError::Unavailable,
-2 | 2 | 9 => ThermalError::Unsupported,
4 => ThermalError::PermissionDenied, n => ThermalError::Driver(n),
}
}
pub fn power_cap_range(device: Device, index: u32) -> Option<(f32, f32)> {
match device {
#[cfg(feature = "cuda")]
Device::Cuda => rlx_cuda::nvml::power_cap_range(index),
#[cfg(feature = "rocm")]
Device::Rocm => rlx_rocm::rsmi::power_cap_range(index),
_ => {
let _ = (device, index);
None
}
}
}
pub fn set_power_cap(device: Device, index: u32, watts: f32) -> Result<(), ThermalError> {
if let Some((min, max)) = power_cap_range(device, index) {
if watts + 0.5 < min || watts - 0.5 > max {
return Err(ThermalError::OutOfRange {
requested: watts,
min,
max,
});
}
}
match device {
#[cfg(feature = "cuda")]
Device::Cuda => rlx_cuda::nvml::set_power_cap(index, watts).map_err(map_nvml),
#[cfg(feature = "rocm")]
Device::Rocm => rlx_rocm::rsmi::set_power_cap(index, watts).map_err(map_rsmi),
_ => {
let _ = (device, index, watts);
Err(ThermalError::Unsupported)
}
}
}
pub fn set_locked_clocks(
device: Device,
index: u32,
min_mhz: u32,
max_mhz: u32,
) -> Result<(), ThermalError> {
let _ = (index, min_mhz, max_mhz);
match device {
#[cfg(feature = "cuda")]
Device::Cuda => {
rlx_cuda::nvml::set_locked_clocks(index, min_mhz, max_mhz).map_err(map_nvml)
}
_ => Err(ThermalError::Unsupported),
}
}
pub fn reset_locked_clocks(device: Device, index: u32) -> Result<(), ThermalError> {
let _ = index;
match device {
#[cfg(feature = "cuda")]
Device::Cuda => rlx_cuda::nvml::reset_locked_clocks(index).map_err(map_nvml),
_ => Err(ThermalError::Unsupported),
}
}
pub fn set_fan_percent(device: Device, index: u32, percent: f32) -> Result<(), ThermalError> {
match device {
#[cfg(feature = "cuda")]
Device::Cuda => rlx_cuda::nvml::set_fan_percent(index, percent).map_err(map_nvml),
#[cfg(feature = "rocm")]
Device::Rocm => rlx_rocm::rsmi::set_fan_percent(index, percent).map_err(map_rsmi),
_ => {
let _ = (device, index, percent);
Err(ThermalError::Unsupported)
}
}
}
pub fn reset_fan(device: Device, index: u32) -> Result<(), ThermalError> {
match device {
#[cfg(feature = "cuda")]
Device::Cuda => rlx_cuda::nvml::reset_fan(index).map_err(map_nvml),
#[cfg(feature = "rocm")]
Device::Rocm => rlx_rocm::rsmi::reset_fan(index).map_err(map_rsmi),
_ => {
let _ = (device, index);
Err(ThermalError::Unsupported)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gpu_thermal_never_panics() {
let all = all_gpu_thermal();
for s in &all {
assert!(device_thermal(s.device, s.index).is_some());
}
let _ = device_thermal_count(Device::Cuda);
let _ = device_thermal_count(Device::Rocm);
}
#[test]
fn control_is_graceful_without_hardware() {
for &d in &[Device::Cpu, Device::Metal, Device::Cuda, Device::Rocm] {
let _ = power_cap_range(d, 0);
assert!(set_power_cap(d, 0, 100.0).is_err() || crate::is_available(d));
assert!(set_fan_percent(d, 0, 50.0).is_err() || crate::is_available(d));
let _ = reset_fan(d, 0);
let _ = reset_locked_clocks(d, 0);
}
assert_eq!(
set_locked_clocks(Device::Rocm, 0, 1000, 1000),
Err(ThermalError::Unsupported)
);
}
#[test]
fn thermal_error_display_is_actionable() {
assert!(ThermalError::PermissionDenied.to_string().contains("root"));
let oor = ThermalError::OutOfRange {
requested: 400.0,
min: 100.0,
max: 290.0,
};
let s = oor.to_string();
assert!(s.contains("400") && s.contains("290"));
}
#[test]
fn snapshot_doesnt_panic() {
let snap = HwSnapshot::collect();
assert!(!snap.os.is_empty());
assert!(!snap.arch.is_empty());
}
#[test]
fn fingerprint_is_stable_across_collects() {
let a = HwSnapshot::collect();
let b = HwSnapshot::collect();
assert_eq!(a.fingerprint(), b.fingerprint());
}
}