qualia_core_db/inference/thermal_telemetry.rs
1//! W7 — real GPU thermal/power telemetry + a detect-and-recommend thermal governor.
2//!
3//! `orchestrator::ThermalGovernor` previously had only a *simulated* implementation
4//! (`CalculusThermalGovernor`, a Newton-cooling ODE). This module adds a REAL one backed by NVIDIA
5//! NVML (via the optional `nvml` feature / `nvml-wrapper`), reading actual GPU temperature and power.
6//!
7//! **Policy: detect + recommend, never silently escalate.** The governor maps live temperature to
8//! `ThermalStatus` and exposes a *recommended* TDP cap; it does NOT change the GPU's power limit.
9//! Enforcement is a separate, explicit, privileged opt-in
10//! (`NvmlThermalGovernor::apply_power_limit_w`) that nothing here calls automatically — a human/admin
11//! policy must invoke it. This is the in-repo form of the human-centric-control norm for the off-grid
12//! / constrained-power target: the machine reports and recommends; the human decides.
13//!
14//! When the `nvml` feature is off, or NVML/the driver is absent (non-NVIDIA host), telemetry degrades
15//! cleanly: `sample_gpu_thermal()` returns `None` and `open_thermal_governor()` returns the
16//! `NullThermalGovernor` (always `Cool`), so callers never need to know whether NVML is present.
17
18#![cfg(not(target_arch = "wasm32"))]
19
20use crate::inference::orchestrator::{NullThermalGovernor, ThermalGovernor, ThermalStatus};
21use std::sync::atomic::{AtomicBool, Ordering};
22
23// ── Auto-cap mode switch (user option; default ON) ────────────────────────────
24// When ON *and* a real NVML governor is active (i.e. there is an NVIDIA card), the governor
25// automatically applies its recommended TDP cap after temperature has been sustained-Critical for
26// `CRITICAL_DWELL` checks, and restores the original limit once the card cools back to Cool. This is a
27// protective default the principal (Timothy) chose for the off-grid / constrained-power target — it
28// only ever REDUCES power under sustained heat (never raises it or acts against the user), it is fully
29// reversible, and it is user-toggleable, so it respects the human-centric-control norm. When OFF the
30// governor is recommend-only (logs an advisory cap, changes nothing). On non-NVIDIA hosts there is no
31// NVML governor, so this flag has no effect. Applying a cap needs admin/root; if that fails the
32// governor degrades to recommend-only and logs it.
33//
34// MODE SWITCH (UI-reachable, like spec-decode): env `QUALIA_LLM_GPU_AUTO_CAP=0` (off) / `=1` (on) at
35// launch; `set_gpu_auto_cap(bool)` at runtime (the UI/host calls this); `gpu_auto_cap_enabled()` to
36// read the effective mode. The env var, when set, overrides the runtime flag in both directions.
37static GPU_AUTO_CAP: AtomicBool = AtomicBool::new(true);
38
39/// Number of consecutive Critical checks before auto-cap engages (hysteresis — ignores transient
40/// spikes). At the decode-loop tick cadence (~every 64 tokens) this is several seconds of sustained
41/// Critical.
42pub const CRITICAL_DWELL: u32 = 3;
43
44/// Enable/disable automatic TDP capping under sustained Critical temperature (`QUALIA_LLM_GPU_AUTO_CAP`).
45/// Runtime mode switch — the desktop UI / host calls this. Default ON (effective only when an NVIDIA
46/// card + NVML are present).
47#[inline]
48pub fn set_gpu_auto_cap(on: bool) {
49 GPU_AUTO_CAP.store(on, Ordering::Relaxed);
50}
51
52/// Whether automatic TDP capping is enabled (env var wins if set, else the runtime flag). Read this to
53/// reflect the current mode in a UI.
54#[inline]
55pub fn gpu_auto_cap_enabled() -> bool {
56 match std::env::var("QUALIA_LLM_GPU_AUTO_CAP").ok().as_deref() {
57 Some("0") | Some("false") => false,
58 Some("1") | Some("true") => true,
59 _ => GPU_AUTO_CAP.load(Ordering::Relaxed),
60 }
61}
62
63/// What the governor should do this check — the pure decision, separated from the NVML side effects so
64/// it is unit-testable. `critical_streak` counts consecutive Critical checks INCLUDING this one;
65/// `capped` is whether we currently hold an applied cap.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ThermalAction {
68 /// Nothing to do.
69 Nothing,
70 /// Advisory only — log the recommended cap; do not change hardware.
71 Recommend,
72 /// Auto-cap is on and Critical has been sustained past the dwell — apply the recommended cap now.
73 ApplyCap,
74 /// Cooled back to Cool after we had applied a cap — restore the original limit.
75 Restore,
76}
77
78/// Pure enforcement decision (see [`ThermalAction`]). Hysteresis: only caps on **sustained** Critical,
79/// holds the cap through Warm, and restores only once back to Cool.
80pub fn decide_thermal_action(
81 status: ThermalStatus,
82 critical_streak: u32,
83 auto_cap: bool,
84 capped: bool,
85) -> ThermalAction {
86 match status {
87 ThermalStatus::Critical => {
88 if auto_cap && !capped && critical_streak >= CRITICAL_DWELL {
89 ThermalAction::ApplyCap
90 } else if !capped {
91 ThermalAction::Recommend
92 } else {
93 ThermalAction::Nothing // already capped — hold
94 }
95 }
96 ThermalStatus::Warm => {
97 if capped {
98 ThermalAction::Nothing // hold the cap through Warm (hysteresis)
99 } else {
100 ThermalAction::Recommend
101 }
102 }
103 ThermalStatus::Cool => {
104 if capped {
105 ThermalAction::Restore
106 } else {
107 ThermalAction::Nothing
108 }
109 }
110 }
111}
112
113/// GPU temperature bands (°C) — the same thresholds the simulated `CalculusThermalGovernor` uses, so
114/// the real and simulated governors classify identically.
115pub const WARM_THRESHOLD_C: u32 = 65;
116pub const CRITICAL_THRESHOLD_C: u32 = 85;
117
118/// Map a GPU temperature to the project `ThermalStatus`.
119#[inline]
120pub fn status_for_temp(temp_c: u32) -> ThermalStatus {
121 if temp_c > CRITICAL_THRESHOLD_C {
122 ThermalStatus::Critical
123 } else if temp_c > WARM_THRESHOLD_C {
124 ThermalStatus::Warm
125 } else {
126 ThermalStatus::Cool
127 }
128}
129
130/// A point-in-time GPU thermal/power reading.
131#[derive(Debug, Clone, Copy)]
132pub struct GpuThermalSample {
133 /// GPU core temperature (°C).
134 pub temp_c: u32,
135 /// Instantaneous board power draw (W).
136 pub power_w: f64,
137 /// Currently enforced power limit / TDP (W).
138 pub power_limit_w: f64,
139 /// Min settable power limit (W) from the driver constraints.
140 pub power_min_w: f64,
141 /// Max settable power limit (W) from the driver constraints.
142 pub power_max_w: f64,
143 /// Thermal classification derived from `temp_c`.
144 pub status: ThermalStatus,
145}
146
147impl GpuThermalSample {
148 /// A *recommended* TDP cap (W) when the GPU is running hot — advisory only; nothing here applies
149 /// it. `None` when `Cool` (no action recommended). Clamped to the driver's `[min, max]` limits.
150 pub fn recommended_power_cap_w(&self) -> Option<f64> {
151 let frac = match self.status {
152 ThermalStatus::Cool => return None,
153 ThermalStatus::Warm => 0.90,
154 ThermalStatus::Critical => 0.80,
155 };
156 let lo = self.power_min_w.max(1.0);
157 let hi = self.power_max_w.max(lo);
158 Some((self.power_limit_w * frac).clamp(lo, hi))
159 }
160}
161
162/// Read one GPU thermal/power sample. `None` when the `nvml` feature is off or NVML/the driver is
163/// unavailable (non-NVIDIA host, no driver). Never panics. Suitable for a UI telemetry poll.
164pub fn sample_gpu_thermal() -> Option<GpuThermalSample> {
165 #[cfg(feature = "nvml")]
166 {
167 nvml_impl::one_shot_sample().ok()
168 }
169 #[cfg(not(feature = "nvml"))]
170 {
171 None
172 }
173}
174
175/// Construct the best available thermal governor: the real NVML one when the `nvml` feature is on and
176/// NVML initializes, else the `NullThermalGovernor` (always `Cool`). Mirrors `open_storage` /
177/// `open_platform_filter` — callers don't branch on platform/feature.
178pub fn open_thermal_governor() -> Box<dyn ThermalGovernor> {
179 #[cfg(feature = "nvml")]
180 {
181 match nvml_impl::NvmlThermalGovernor::new() {
182 Ok(g) => {
183 log::info!("W7|thermal|NVML governor active: {}", g.device_label());
184 return Box::new(g);
185 }
186 Err(e) => {
187 log::info!("W7|thermal|NVML unavailable ({e}) — NullThermalGovernor (always Cool)");
188 }
189 }
190 }
191 Box::new(NullThermalGovernor)
192}
193
194/// Periodic thermal check for the decode hot path — samples a RESIDENT NVML governor and, per the
195/// auto-cap user option, enforces (sustained Critical → apply the recommended cap; cooled → restore)
196/// or recommends. Cheap (an NVML read); call every N decode tokens. No-op when the `nvml` feature is
197/// off or NVML/the driver is unavailable. The resident governor keeps the auto-cap hysteresis state
198/// (Critical streak, capped flag, saved limit) across ticks.
199pub fn thermal_tick() {
200 #[cfg(feature = "nvml")]
201 {
202 use std::sync::OnceLock;
203 static RESIDENT: OnceLock<Option<nvml_impl::NvmlThermalGovernor>> = OnceLock::new();
204 if let Some(g) = RESIDENT.get_or_init(|| nvml_impl::NvmlThermalGovernor::new().ok()) {
205 g.tick();
206 }
207 }
208}
209
210/// The real NVML-backed governor. Construct directly (`NvmlThermalGovernor::new()`) when you need the
211/// privileged `apply_power_limit_w` enforcement path or repeated `sample()`s; use
212/// `open_thermal_governor()` for the trait-object detect+recommend role.
213#[cfg(feature = "nvml")]
214pub use nvml_impl::NvmlThermalGovernor;
215
216#[cfg(feature = "nvml")]
217mod nvml_impl {
218 use super::*;
219 use nvml_wrapper::enum_wrappers::device::TemperatureSensor;
220 use nvml_wrapper::Nvml;
221 use std::sync::atomic::AtomicU32;
222
223 /// A real NVML-backed thermal governor over GPU 0. Detect + recommend only; the sole enforcement
224 /// path (`apply_power_limit_w`) is explicit, privileged, and never called automatically.
225 pub struct NvmlThermalGovernor {
226 nvml: Nvml,
227 index: u32,
228 label: String,
229 /// Consecutive Critical checks (hysteresis for auto-cap).
230 critical_streak: AtomicU32,
231 /// Whether we currently hold an applied cap.
232 capped: AtomicBool,
233 /// The enforced power limit (mW) captured before we capped, restored on cool-down.
234 saved_limit_mw: AtomicU32,
235 }
236
237 impl NvmlThermalGovernor {
238 pub fn new() -> Result<Self, String> {
239 let nvml = Nvml::init().map_err(|e| format!("NVML init: {e}"))?;
240 let index = 0u32;
241 let label = nvml
242 .device_by_index(index)
243 .and_then(|d| d.name())
244 .unwrap_or_else(|_| "NVIDIA GPU".to_string());
245 Ok(Self {
246 nvml,
247 index,
248 label,
249 critical_streak: AtomicU32::new(0),
250 capped: AtomicBool::new(false),
251 saved_limit_mw: AtomicU32::new(0),
252 })
253 }
254
255 pub fn device_label(&self) -> &str {
256 &self.label
257 }
258
259 /// Read one live sample from the resident NVML handle.
260 pub fn sample(&self) -> Result<GpuThermalSample, String> {
261 let dev = self
262 .nvml
263 .device_by_index(self.index)
264 .map_err(|e| format!("device: {e}"))?;
265 let temp_c = dev
266 .temperature(TemperatureSensor::Gpu)
267 .map_err(|e| format!("temperature: {e}"))?;
268 let power_w = dev
269 .power_usage()
270 .map(|mw| mw as f64 / 1000.0)
271 .unwrap_or(0.0);
272 let power_limit_w = dev
273 .enforced_power_limit()
274 .map(|mw| mw as f64 / 1000.0)
275 .unwrap_or(0.0);
276 let (power_min_w, power_max_w) = dev
277 .power_management_limit_constraints()
278 .map(|c| (c.min_limit as f64 / 1000.0, c.max_limit as f64 / 1000.0))
279 .unwrap_or((0.0, power_limit_w));
280 Ok(GpuThermalSample {
281 temp_c,
282 power_w,
283 power_limit_w,
284 power_min_w,
285 power_max_w,
286 status: status_for_temp(temp_c),
287 })
288 }
289
290 /// EXPLICIT, PRIVILEGED opt-in enforcement — set the GPU power limit (W). This is the ONLY
291 /// method that mutates hardware state; it is NEVER called automatically by the governor and
292 /// requires admin/root (returns `Err` otherwise). A human/admin policy must invoke it (no
293 /// silent escalation).
294 pub fn apply_power_limit_w(&self, watts: f64) -> Result<(), String> {
295 self.apply_power_limit_mw((watts * 1000.0).round().max(0.0) as u32)
296 }
297
298 fn apply_power_limit_mw(&self, mw: u32) -> Result<(), String> {
299 let mut dev = self
300 .nvml
301 .device_by_index(self.index)
302 .map_err(|e| format!("device: {e}"))?;
303 dev.set_power_management_limit(mw)
304 .map_err(|e| format!("set_power_management_limit (needs admin): {e}"))
305 }
306
307 /// One thermal check: sample, decide (with hysteresis), then enforce (auto-cap on + sustained
308 /// Critical → apply the recommended cap; cooled after a cap → restore) or recommend-only.
309 /// Call periodically (e.g. every N decode tokens). Never panics; a failed NVML apply degrades
310 /// to recommend-only + a log line.
311 pub fn tick(&self) {
312 let s = match self.sample() {
313 Ok(s) => s,
314 Err(_) => return,
315 };
316 let streak = if matches!(s.status, ThermalStatus::Critical) {
317 self.critical_streak.fetch_add(1, Ordering::Relaxed) + 1
318 } else {
319 self.critical_streak.store(0, Ordering::Relaxed);
320 0
321 };
322 let auto_cap = gpu_auto_cap_enabled();
323 let capped = self.capped.load(Ordering::Relaxed);
324 match decide_thermal_action(s.status, streak, auto_cap, capped) {
325 ThermalAction::ApplyCap => {
326 if let Some(cap) = s.recommended_power_cap_w() {
327 self.saved_limit_mw
328 .store((s.power_limit_w * 1000.0).round() as u32, Ordering::Relaxed);
329 match self.apply_power_limit_w(cap) {
330 Ok(()) => {
331 self.capped.store(true, Ordering::Relaxed);
332 log::warn!(
333 "W7|thermal|AUTO-CAP: {} sustained {}\u{b0}C — capped {:.0}W \u{2192} {:.0}W (reversible; auto_cap on)",
334 self.label, s.temp_c, s.power_limit_w, cap
335 );
336 }
337 Err(e) => log::warn!(
338 "W7|thermal|auto-cap FAILED (needs admin) at {}\u{b0}C — RECOMMEND {:.0}W only: {e}",
339 s.temp_c, cap
340 ),
341 }
342 }
343 }
344 ThermalAction::Restore => {
345 let saved = self.saved_limit_mw.load(Ordering::Relaxed);
346 if saved > 0 {
347 match self.apply_power_limit_mw(saved) {
348 Ok(()) => log::info!(
349 "W7|thermal|cooled to {}\u{b0}C — restored power limit {:.0}W",
350 s.temp_c,
351 saved as f64 / 1000.0
352 ),
353 Err(e) => log::warn!("W7|thermal|restore power limit FAILED: {e}"),
354 }
355 }
356 self.capped.store(false, Ordering::Relaxed);
357 }
358 ThermalAction::Recommend => {
359 if let Some(cap) = s.recommended_power_cap_w() {
360 log::warn!(
361 "W7|thermal|{:?} {}\u{b0}C {:.0}W/{:.0}W — RECOMMEND cap {:.0}W (advisory)",
362 s.status, s.temp_c, s.power_w, s.power_limit_w, cap
363 );
364 }
365 }
366 ThermalAction::Nothing => {}
367 }
368 }
369 }
370
371 impl ThermalGovernor for NvmlThermalGovernor {
372 fn get_thermal_state(&self) -> ThermalStatus {
373 self.sample()
374 .map(|s| s.status)
375 .unwrap_or(ThermalStatus::Cool)
376 }
377
378 fn adjust_policy(&self, _status: ThermalStatus) {
379 // Full check: recommend, or auto-cap/restore when the user option is on (see `tick`).
380 self.tick();
381 }
382 }
383
384 /// One-shot read (init NVML, sample, drop). For repeated polling prefer a resident
385 /// `NvmlThermalGovernor` to avoid re-initializing NVML each call.
386 pub fn one_shot_sample() -> Result<GpuThermalSample, String> {
387 NvmlThermalGovernor::new()?.sample()
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn temp_bands_classify_correctly() {
397 assert_eq!(status_for_temp(30), ThermalStatus::Cool);
398 assert_eq!(status_for_temp(WARM_THRESHOLD_C), ThermalStatus::Cool); // boundary is inclusive-Cool
399 assert_eq!(status_for_temp(WARM_THRESHOLD_C + 1), ThermalStatus::Warm);
400 assert_eq!(status_for_temp(80), ThermalStatus::Warm);
401 assert_eq!(status_for_temp(CRITICAL_THRESHOLD_C), ThermalStatus::Warm);
402 assert_eq!(
403 status_for_temp(CRITICAL_THRESHOLD_C + 1),
404 ThermalStatus::Critical
405 );
406 assert_eq!(status_for_temp(95), ThermalStatus::Critical);
407 }
408
409 #[test]
410 fn recommended_cap_is_advisory_and_clamped() {
411 let mk = |temp, limit, min, max| GpuThermalSample {
412 temp_c: temp,
413 power_w: 0.0,
414 power_limit_w: limit,
415 power_min_w: min,
416 power_max_w: max,
417 status: status_for_temp(temp),
418 };
419 // Cool → no recommendation.
420 assert!(mk(40, 70.0, 40.0, 90.0).recommended_power_cap_w().is_none());
421 // Warm → 90% of the enforced limit.
422 let warm = mk(70, 70.0, 40.0, 90.0).recommended_power_cap_w().unwrap();
423 assert!((warm - 63.0).abs() < 1e-6, "warm cap {warm}");
424 // Critical → 80%, clamped up to the driver minimum.
425 let crit = mk(90, 70.0, 60.0, 90.0).recommended_power_cap_w().unwrap();
426 assert!(
427 (crit - 60.0).abs() < 1e-6,
428 "critical cap clamped to min: {crit}"
429 );
430 }
431
432 #[test]
433 fn auto_cap_decision_hysteresis() {
434 use ThermalAction::*;
435 use ThermalStatus::*;
436 // Cool, nothing held → nothing; Cool while capped → restore.
437 assert_eq!(decide_thermal_action(Cool, 0, true, false), Nothing);
438 assert_eq!(decide_thermal_action(Cool, 0, true, true), Restore);
439 // Warm recommends when uncapped; holds the cap when capped (hysteresis).
440 assert_eq!(decide_thermal_action(Warm, 0, true, false), Recommend);
441 assert_eq!(decide_thermal_action(Warm, 0, true, true), Nothing);
442 // Critical below the dwell → recommend only, even with auto-cap on.
443 assert_eq!(
444 decide_thermal_action(Critical, CRITICAL_DWELL - 1, true, false),
445 Recommend
446 );
447 // Critical sustained past the dwell with auto-cap ON → apply the cap.
448 assert_eq!(
449 decide_thermal_action(Critical, CRITICAL_DWELL, true, false),
450 ApplyCap
451 );
452 // Same, but auto-cap OFF → recommend only (the user option gates enforcement).
453 assert_eq!(
454 decide_thermal_action(Critical, CRITICAL_DWELL, false, false),
455 Recommend
456 );
457 // Already capped and still Critical → hold (don't re-apply).
458 assert_eq!(
459 decide_thermal_action(Critical, CRITICAL_DWELL + 5, true, true),
460 Nothing
461 );
462 }
463
464 #[test]
465 fn auto_cap_toggle_roundtrips() {
466 set_gpu_auto_cap(false);
467 assert!(!gpu_auto_cap_enabled() || std::env::var("QUALIA_LLM_GPU_AUTO_CAP").is_ok());
468 set_gpu_auto_cap(true);
469 assert!(
470 gpu_auto_cap_enabled()
471 || std::env::var("QUALIA_LLM_GPU_AUTO_CAP").as_deref() == Ok("0")
472 );
473 }
474
475 /// Hardware smoke test — only compiled with `--features nvml` and only meaningful on an NVIDIA
476 /// host. Reads one live sample and sanity-checks it; skips gracefully if NVML is absent.
477 #[cfg(feature = "nvml")]
478 #[test]
479 fn nvml_live_sample_smoke() {
480 match sample_gpu_thermal() {
481 Some(s) => {
482 println!(
483 "[w7] {:?} temp={}\u{b0}C power={:.1}W limit={:.1}W [{:.1}..{:.1}]W rec={:?}",
484 s.status,
485 s.temp_c,
486 s.power_w,
487 s.power_limit_w,
488 s.power_min_w,
489 s.power_max_w,
490 s.recommended_power_cap_w()
491 );
492 assert!(
493 s.temp_c > 0 && s.temp_c < 130,
494 "implausible GPU temp {}",
495 s.temp_c
496 );
497 assert_eq!(s.status, status_for_temp(s.temp_c));
498 }
499 None => eprintln!("[w7] NVML unavailable on this host — smoke test skipped"),
500 }
501 }
502}