1use 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#[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 pub total_cpus: usize,
50 pub perf_cores: usize,
52 pub l1d_bytes: usize,
54 pub l2_bytes: usize,
56 pub cache_line: usize,
58 pub thermal: ThermalState,
59}
60
61impl HwSnapshot {
62 pub fn collect() -> Self {
66 let total_cpus = std::thread::available_parallelism()
67 .map(|n| n.get())
68 .unwrap_or(2);
69
70 #[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 #[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 #[cfg(target_os = "macos")]
97 {
98 snap.thermal = read_pmset_thermal().unwrap_or(ThermalState::Unknown);
99 }
100
101 snap
102 }
103
104 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 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 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 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#[derive(Debug, Clone, PartialEq)]
259pub struct GpuThermal {
260 pub device: Device,
262 pub index: u32,
264 pub name: Option<String>,
266 pub temp_c: Option<f32>,
268 pub hotspot_c: Option<f32>,
270 pub mem_temp_c: Option<f32>,
272 pub power_w: Option<f32>,
274 pub power_cap_w: Option<f32>,
276 pub fan_percent: Option<f32>,
278 pub clock_mhz: Option<u32>,
280 pub util_percent: Option<f32>,
282}
283
284impl GpuThermal {
285 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
295pub 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
310pub 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
357pub 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#[derive(Debug, Clone, PartialEq)]
381pub enum ThermalError {
382 Unavailable,
384 Unsupported,
387 PermissionDenied,
389 OutOfRange { requested: f32, min: f32, max: f32 },
391 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#[cfg(feature = "cuda")]
422fn map_nvml(rc: i32) -> ThermalError {
423 match rc {
424 -1 => ThermalError::Unavailable,
425 -2 | 3 => ThermalError::Unsupported, 4 => ThermalError::PermissionDenied, 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 -2 | 2 | 9 => ThermalError::Unsupported,
437 4 => ThermalError::PermissionDenied, n => ThermalError::Driver(n),
439 }
440}
441
442pub 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
457pub fn set_power_cap(device: Device, index: u32, watts: f32) -> Result<(), ThermalError> {
464 if let Some((min, max)) = power_cap_range(device, index) {
465 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
486pub 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
508pub 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
519pub 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
535pub 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 let all = all_gpu_thermal();
558 for s in &all {
559 assert!(device_thermal(s.device, s.index).is_some());
561 }
562 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 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 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 assert!(!snap.os.is_empty());
605 assert!(!snap.arch.is_empty());
606 }
607
608 #[test]
609 fn fingerprint_is_stable_across_collects() {
610 let a = HwSnapshot::collect();
613 let b = HwSnapshot::collect();
614 assert_eq!(a.fingerprint(), b.fingerprint());
615 }
616}