gpuviewer_core/nvidia.rs
1//! NVIDIA backend — direct NVML calls via `nvml-wrapper` (runtime-loads the driver's own
2//! `libnvidia-ml.so.1` / `nvml.dll`). No nvidia-smi anywhere: nvidia-smi is itself just a
3//! CLI wrapper over this same library.
4//!
5//! Per the domain rules in CLAUDE.md:
6//! - `NOT_SUPPORTED` (and any other per-metric error) maps to `None`, never a failure.
7//! - The `.so.1` path is tried first on Linux: driver-only installs don't ship the
8//! unversioned `.so` symlink (that comes with the CUDA toolkit) — the exact pitfall
9//! bottom hit.
10//! - Throttle mapping is edge-honest: GPU idle is deliberately NOT narrated as throttling
11//! (an idle GPU is not slow). Configuration limiters (applications-clocks / display-clock
12//! settings) map to the catch-all `other` so they are surfaced without being mislabeled as
13//! a thermal or power slowdown. See [`map_throttle`].
14//! - WSL2 is detected once at init: per-process GPU info is N/A *at the driver level*
15//! there, so `StaticInfo::process_hint` explains the empty process table up front
16//! instead of crashing on it (nvtop #432 is the cautionary tale).
17//!
18//! Windows (v1.5, docs/design/cross-platform.md §2) — NVML is the device-truth half of the
19//! dual-source split; the shared PDH snapshot (`wddm::pdh`, joined per device by the §2.5
20//! LUID↔PCI match built at init) is the per-process half:
21//! - `nvml.dll` loading: modern drivers (≥461.55, ~2020+) install it into
22//! `C:\Windows\System32`, which is on the default `LoadLibraryExW` search path — plain
23//! `Nvml::init()` is the whole story. We do NOT probe the legacy
24//! `C:\Program Files\NVIDIA Corporation\NVSMI\` directory; the documented driver floor
25//! is R510+ (early 2022). Load failures (no driver, pre-2020 NVSMI-only driver,
26//! `DRIVER_NOT_LOADED`) map to `BackendError::Unavailable` — normal, backend skipped.
27//! - WDDM realities: per-metric `NOT_SUPPORTED` → `None` exactly as on Linux; per-process
28//! `usedGpuMemory` is *always* Unavailable under WDDM (the Windows kernel memory manager
29//! owns that accounting, NVML architecturally cannot see it) → `None`, never 0 — the
30//! per-pid `GPU Process Memory\Dedicated Usage` counters from the shared PDH snapshot
31//! fill that column (§2.4), and per-pid engine busy% fills `util_pct`;
32//! `process_utilization_stats` is deliberately not called on Windows (§2.4); throttle
33//! reasons ARE empirically readable under WDDM and keep the same mapping as Linux;
34//! device `mem_used_bytes` prefers PDH's adapter-level Dedicated Usage (VidMm truth)
35//! over NVML's virtualized driver view (§2.3).
36//! - Driver model: WDDM is the GeForce default; TCC (WDM) is deprecated Quadro/Tesla-only
37//! non-display mode where NVML per-process accounting works — no special path beyond an
38//! accurate `process_hint`. A future model value (MCDM) surfaces as `UnexpectedVariant`
39//! and is handled, never a crash (§2.6).
40//! - MIG never arises on Windows (NVIDIA ships MIG on Linux only) — the MIG
41//! NOT_SUPPORTED-on-device-utilization caveat is a Linux-only concern.
42
43use std::collections::HashMap;
44#[cfg(target_os = "linux")]
45use std::ffi::OsStr;
46
47use nvml_wrapper::bitmasks::device::ThrottleReasons as NvmlThrottle;
48#[cfg(target_os = "windows")]
49use nvml_wrapper::enum_wrappers::device::DriverModel;
50use nvml_wrapper::enum_wrappers::device::{Clock, TemperatureSensor, TemperatureThreshold};
51use nvml_wrapper::enums::device::UsedGpuMemory;
52#[cfg(any(target_os = "windows", test))]
53use nvml_wrapper::error::NvmlError;
54use nvml_wrapper::Nvml;
55
56use crate::backend::{BackendError, GpuBackend};
57use crate::model::{
58 now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample, StaticInfo, ThrottleReasons,
59 Vendor,
60};
61
62/// Map any per-metric NVML error to `None` — absence is a normal outcome.
63fn opt<T, E>(r: Result<T, E>) -> Option<T> {
64 r.ok()
65}
66
67/// WSL kernels self-identify via the release string (e.g.
68/// `5.15.167.4-microsoft-standard-WSL2`; WSL1-era kernels used capital-M `Microsoft`).
69#[cfg(any(target_os = "linux", test))]
70fn is_wsl(osrelease: &str) -> bool {
71 osrelease.to_ascii_lowercase().contains("microsoft")
72}
73
74/// `StaticInfo::process_hint` for environments where the process list is known-absent.
75/// WSL2 passes the GPU through but exposes no per-process info at the driver level —
76/// say so up front instead of rendering a silently-empty table. A failed read just means
77/// "nothing to explain"; this must never fail init.
78#[cfg(target_os = "linux")]
79fn wsl_process_hint() -> Option<String> {
80 #[cfg(target_os = "linux")]
81 if std::fs::read_to_string("/proc/sys/kernel/osrelease").is_ok_and(|rel| is_wsl(&rel)) {
82 return Some(
83 "per-process GPU info is unavailable under WSL2 (driver-level limitation) — \
84 device metrics are unaffected"
85 .into(),
86 );
87 }
88 None
89}
90
91/// Pure mirror of the per-OS library-loading decision in [`NvidiaBackend::init`] (kept in
92/// sync by the unit tests, which run on every OS):
93/// - Linux: try the versioned soname first — driver-only installs ship only
94/// `libnvidia-ml.so.1` (the unversioned `.so` symlink comes with the CUDA toolkit).
95/// - Windows: no explicit path — modern drivers (≥461.55) put `nvml.dll` in System32,
96/// already on the default loader search path; an explicit relative path would just be
97/// one doomed `LoadLibrary` before the same default lookup. The legacy NVSMI directory
98/// is deliberately not probed (the documented floor is R510+, §2.2).
99#[cfg(test)]
100fn explicit_lib_path_for(target_os: &str) -> Option<&'static str> {
101 match target_os {
102 "linux" => Some("libnvidia-ml.so.1"),
103 _ => None,
104 }
105}
106
107/// Windows driver model reduced to the cases that change our messaging. Mirrors NVML's
108/// `nvmlDriverModel_t`: WDDM (display GPUs — the GeForce default), WDM a.k.a. TCC
109/// (non-display compute; Quadro/Tesla-only and deprecated), plus a future-proofing bucket
110/// for values nvml-wrapper 0.12 cannot decode (MCDM = 2, Microsoft's compute-only driver
111/// model, is missing from its enum and surfaces as `NvmlError::UnexpectedVariant`).
112#[cfg(any(target_os = "windows", test))]
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114enum DriverModelClass {
115 Wddm,
116 Tcc,
117 /// The driver reported a model this build does not recognize (e.g. future MCDM).
118 /// Treated like TCC-class (non-display) for messaging, per §2.6 — never a crash.
119 UnknownVariant,
120}
121
122/// Classify a *failed* `driver_model()` query. Pure so the policy unit-tests on any OS:
123/// - `UnexpectedVariant` is NVML successfully reporting a driver model newer than this
124/// build's enum (MCDM) — keep the device, flag the unknown model.
125/// - Any other error → assume WDDM: it is the default for every display-attached GPU on
126/// Windows, and the WDDM hint is the one that matters. Under a TCC board this default
127/// is merely redundant next to a populated per-process column; a missing hint under
128/// WDDM would leave an empty column unexplained — the worse honesty failure.
129#[cfg(any(target_os = "windows", test))]
130fn classify_driver_model_err(e: &NvmlError) -> DriverModelClass {
131 match e {
132 NvmlError::UnexpectedVariant(_) => DriverModelClass::UnknownVariant,
133 _ => DriverModelClass::Wddm,
134 }
135}
136
137/// Whether the §2.4 PDH fill of per-process columns is actually available for a device.
138/// The hint and caveat builders take this so they only ever name a source that the code
139/// in this build genuinely delivers — naming an undelivered source is a lie with a
140/// citation attached.
141#[cfg(any(target_os = "windows", test))]
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143// A Windows build WITHOUT the wddm feature only ever constructs NoPdh; the other
144// variants still appear in the (exhaustive, honest) match arms — not dead design.
145#[cfg_attr(all(target_os = "windows", not(feature = "wddm")), allow(dead_code))]
146enum PdhAttribution {
147 /// The shared PDH snapshot is live and this device's LUID↔PCI match (§2.5)
148 /// succeeded: per-process VRAM/util rows are filled from Windows GPU performance
149 /// counters every tick.
150 Available,
151 /// PDH has GPU counters but no enumerated adapter's PCI address matched this
152 /// device — the honest terminal state of §2.5: device metrics keep flowing,
153 /// per-process columns stay None, and the hint says exactly that.
154 NoLuidMatch,
155 /// No `GPU Engine` PDH object in this session (no WDDM 2.0 GPU/driver — GPU-less
156 /// VMs, ancient drivers), or this build carries no PDH plumbing at all (built
157 /// without the `wddm` feature): nothing exists to attribute from.
158 NoPdh,
159}
160
161/// `StaticInfo::process_hint` for a Windows device, from its driver model (§2.7) and the
162/// real availability of PDH attribution. Pure so it unit-tests on any OS.
163#[cfg(any(target_os = "windows", test))]
164fn windows_process_hint(model: DriverModelClass, pdh: PdhAttribution) -> Option<String> {
165 match model {
166 // Under WDDM the Windows kernel owns per-process VRAM accounting; NVML's
167 // usedGpuMemory is architecturally always Unavailable. What we say depends on
168 // whether the PDH fill is actually delivering those columns on this device.
169 DriverModelClass::Wddm => Some(match pdh {
170 PdhAttribution::Available => {
171 "per-process VRAM/utilization come from Windows (WDDM) GPU performance \
172 counters, not the NVIDIA driver — NVML cannot see them under WDDM"
173 .into()
174 }
175 PdhAttribution::NoLuidMatch => {
176 "could not attribute per-process GPU data (LUID\u{2194}PCI match failed) \
177 — per-process VRAM/utilization unavailable; device metrics unaffected"
178 .into()
179 }
180 PdhAttribution::NoPdh => "per-process GPU stats unavailable: no WDDM 2.0 GPU/driver \
181 (Windows exposes them via GPU performance counters)"
182 .into(),
183 }),
184 // TCC (non-display): NVML's own per-process accounting works — nothing to explain.
185 DriverModelClass::Tcc => None,
186 DriverModelClass::UnknownVariant => Some(
187 "driver reports an unknown compute driver model (newer than this build) — \
188 per-process GPU data is shown as NVML reports it and may be incomplete"
189 .into(),
190 ),
191 }
192}
193
194/// `StaticInfo::source_caveat` for a Windows device (§2.3/§5.4): names where the
195/// memory-used number really comes from, per the device's actual PDH attribution state.
196/// Pure so it unit-tests on any OS.
197#[cfg(any(target_os = "windows", test))]
198fn windows_source_caveat(pdh: PdhAttribution) -> Option<String> {
199 Some(match pdh {
200 PdhAttribution::Available => {
201 "memory used is Windows' VidMm dedicated usage (PDH), falling back to the \
202 NVIDIA driver's view of WDDM-virtualized memory when PDH has no sample"
203 .into()
204 }
205 // No PDH source on this device: every used-memory number is the driver's view
206 // of a virtualized space, which can diverge from the OS (VidMm) number.
207 PdhAttribution::NoLuidMatch | PdhAttribution::NoPdh => {
208 "memory used is the NVIDIA driver's view of WDDM-virtualized memory and can \
209 diverge from the OS (VidMm) number"
210 .into()
211 }
212 })
213}
214
215/// Reduce a `driver_model()` result to our class. `Ok` handles WDDM/TCC; decode failures
216/// and query errors go through [`classify_driver_model_err`] (the pure, tested half).
217#[cfg(target_os = "windows")]
218fn device_driver_model(d: &nvml_wrapper::Device<'_>) -> DriverModelClass {
219 match d.driver_model() {
220 Ok(state) => match state.current {
221 DriverModel::WDDM => DriverModelClass::Wddm,
222 // NVML calls TCC "WDM" for historical reasons.
223 DriverModel::WDM => DriverModelClass::Tcc,
224 },
225 Err(e) => classify_driver_model_err(&e),
226 }
227}
228
229/// Pre-R510 drivers (before early 2022) lack the `_v3` process-list symbols nvml-wrapper
230/// 0.12 binds; that surfaces as `FailedToLoadSymbol`, and ONLY that error warrants the
231/// one-shot `_v2` retry (§2.2). Anything else (`NOT_SUPPORTED`, GPU lost, ...) already
232/// means "no list this tick" and must not trigger a second call.
233#[cfg(any(target_os = "windows", test))]
234fn should_retry_v2(e: &NvmlError) -> bool {
235 matches!(e, NvmlError::FailedToLoadSymbol(_))
236}
237
238/// Per-process VRAM honesty, shared by Linux and Windows and *load-bearing* on Windows:
239/// under WDDM, NVML reports `Unavailable` for every process (the Windows kernel memory
240/// manager owns that accounting — NVML architecturally cannot see it), and the only honest
241/// mapping is `None` — never 0, which would render as "this process uses no VRAM". The
242/// wddm backend's PDH `GPU Process Memory\Dedicated Usage` counters are the fallback
243/// source for this field on Windows. `Unavailable` is also the legitimate WSL2 outcome.
244/// (TCC-mode boards report real values through the same `Used` arm — no special path.)
245fn used_gpu_memory_bytes(m: &UsedGpuMemory) -> Option<u64> {
246 match m {
247 UsedGpuMemory::Used(b) => Some(*b),
248 UsedGpuMemory::Unavailable => None,
249 }
250}
251
252pub struct NvidiaBackend {
253 nvml: Nvml,
254 /// (nvml index, stable id) established at init.
255 devs: Vec<(u32, DeviceId)>,
256 /// Per-device adapter LUID from the §2.5 LUID↔PCI match, parallel to `devs` —
257 /// the key that filters the shared PDH snapshot for this device. `None` = the match
258 /// failed: an **honest terminal state** (§2.5) — the device keeps its NVML metrics,
259 /// per-process columns stay `None`, and `process_hint` says why. Never force a match.
260 #[cfg(all(target_os = "windows", feature = "wddm"))]
261 luids: Vec<Option<(i32, u32)>>,
262 /// Per-device watermark for `process_utilization_stats` sampling. Linux-only: that
263 /// API is deliberately not called on Windows (§2.4 — see `refresh_processes`).
264 #[cfg(target_os = "linux")]
265 last_util_ts: HashMap<u32, u64>,
266 /// Set once at init: explanation for a known-incomplete process list (WSL2), if any.
267 /// Linux-only: the Windows hint depends on the per-device driver model and is
268 /// computed per device in `static_info` instead.
269 #[cfg(target_os = "linux")]
270 process_hint: Option<String>,
271 /// Turns the kernel's cumulative per-PID CPU counter into a per-tick rate. Linux-only:
272 /// the CPU%/container columns come from `/proc`, which Windows does not have — both stay
273 /// `None` there. Shared with the other Linux backends via `crate::proc_meta`.
274 #[cfg(target_os = "linux")]
275 cpu: crate::proc_meta::CpuTracker,
276}
277
278impl NvidiaBackend {
279 pub fn init() -> Result<Self, BackendError> {
280 // Driver-only Linux installs ship only libnvidia-ml.so.1; fall back to the
281 // default loader.
282 #[cfg(target_os = "linux")]
283 let nvml = Nvml::builder()
284 .lib_path(OsStr::new("libnvidia-ml.so.1"))
285 .init()
286 .or_else(|_| Nvml::init())
287 .map_err(|e| BackendError::Unavailable(format!("NVML unavailable: {e}")))?;
288
289 // Windows: nvml.dll lives in System32 (drivers ≥461.55), on the default
290 // LoadLibraryExW search path — the plain default load is the whole story (§2.1).
291 // Failure (no NVIDIA driver, pre-2020 NVSMI-only layout, DRIVER_NOT_LOADED) is a
292 // normal outcome: Unavailable, backend skipped, the wddm backend covers the GPU.
293 #[cfg(target_os = "windows")]
294 let nvml = Nvml::init()
295 .map_err(|e| BackendError::Unavailable(format!("NVML unavailable: {e}")))?;
296
297 let count = nvml
298 .device_count()
299 .map_err(|e| BackendError::Unavailable(format!("NVML device count: {e}")))?;
300
301 let mut devs = Vec::new();
302 for i in 0..count {
303 let Ok(dev) = nvml.device_by_index(i) else {
304 continue;
305 };
306 // The PCI-address id is what cross-backend dedupe keys on; on Windows it is
307 // also the value the wddm backend's LUID→BDF map joins against (§2.5).
308 let id = match dev.pci_info() {
309 Ok(pci) => DeviceId(pci.bus_id.to_lowercase()),
310 Err(_) => DeviceId(format!("nvml:{i}")),
311 };
312 devs.push((i, id));
313 }
314 if devs.is_empty() {
315 return Err(BackendError::Unavailable(
316 "NVML loaded but no devices".into(),
317 ));
318 }
319
320 // §2.5: build the session's LUID↔PCI map once at init (LUIDs are session-scoped
321 // but stable within one) by matching each NVML device's normalized PCI BDF
322 // against the D3DKMT-derived BDF of every DXGI adapter. Both sides go through
323 // the one shared `normalize_pci_id` rule — the same equality the collector's
324 // first-wins dedupe uses. Also prime the shared PDH query so the first Engine
325 // tick is the *second* collection and rate counters can already produce values.
326 #[cfg(all(target_os = "windows", feature = "wddm"))]
327 let luids: Vec<Option<(i32, u32)>> = {
328 let _ = crate::wddm::pdh::shared().snapshot(now_ms());
329 let adapters = crate::wddm::adapters::enumerate();
330 devs.iter()
331 .map(|(_, id)| {
332 let key = crate::model::normalize_pci_id(&id.0)?;
333 adapters
334 .iter()
335 .find(|a| {
336 a.pci_bdf
337 .as_deref()
338 .and_then(crate::model::normalize_pci_id)
339 .as_deref()
340 == Some(key.as_str())
341 })
342 .map(|a| (a.luid_high, a.luid_low))
343 })
344 .collect()
345 };
346
347 Ok(Self {
348 nvml,
349 devs,
350 #[cfg(all(target_os = "windows", feature = "wddm"))]
351 luids,
352 #[cfg(target_os = "linux")]
353 last_util_ts: HashMap::new(),
354 #[cfg(target_os = "linux")]
355 process_hint: wsl_process_hint(),
356 #[cfg(target_os = "linux")]
357 cpu: crate::proc_meta::CpuTracker::new(),
358 })
359 }
360
361 fn index_of(&self, dev: &DeviceId) -> Result<u32, BackendError> {
362 self.devs
363 .iter()
364 .find(|(_, id)| id == dev)
365 .map(|(i, _)| *i)
366 .ok_or_else(|| BackendError::DeviceNotFound(dev.clone()))
367 }
368
369 /// This device's matched adapter LUID, if the §2.5 LUID↔PCI match succeeded.
370 #[cfg(all(target_os = "windows", feature = "wddm"))]
371 fn luid_of(&self, dev: &DeviceId) -> Option<(i32, u32)> {
372 self.devs
373 .iter()
374 .position(|(_, id)| id == dev)
375 .and_then(|p| self.luids.get(p).copied().flatten())
376 }
377
378 fn process_name(&self, pid: u32) -> String {
379 if let Ok(name) = self.nvml.sys_process_name(pid, 128) {
380 // NVML returns the full path; keep the basename for readability (both
381 // separators: Linux `/`, Windows `\`).
382 if let Some(base) = name.rsplit(['/', '\\']).next() {
383 if !base.is_empty() {
384 return base.to_string();
385 }
386 }
387 return name;
388 }
389 // Fallback: /proc/<pid>/comm on Linux.
390 #[cfg(target_os = "linux")]
391 if let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) {
392 let comm = comm.trim();
393 if !comm.is_empty() {
394 return comm.to_string();
395 }
396 }
397 format!("pid {pid}")
398 }
399}
400
401/// Map NVML's clocks-event/throttle reason bitmask to our category struct. Pure (takes the
402/// raw bitflags, touches no device) so it unit-tests without a GPU.
403///
404/// The mapping is deliberately exhaustive — rivals (research 05 competitive analysis) skip
405/// SW_POWER_CAP and SW_THERMAL_SLOWDOWN, which are the *dominant* causes on power-limited
406/// consumer parts (a 4090 Laptop spends most of its life power-capped, not at the silicon's
407/// hardware brake), so a monitor that only reports HW slowdown silently misses why the card
408/// is slow:
409/// - SW_POWER_CAP → `power_cap` (the software power-scaling algorithm reducing clocks).
410/// - SW/HW_THERMAL_SLOWDOWN → `thermal` (over GPU/memory temp; either tier is "thermal").
411/// - HW_SLOWDOWN / HW_POWER_BRAKE_SLOWDOWN → `hw_slowdown` (the 2x+ hardware brake — temp,
412/// external power-brake assertion, or fast-trigger overcurrent).
413/// - SYNC_BOOST → `sync_boost` (held down by another GPU in the sync-boost group).
414/// - APPLICATIONS_CLOCKS_SETTING, DISPLAY_CLOCK_SETTING, and any future/unrecognized bit →
415/// `other`. These are real clock limiters worth surfacing, but they are user/display
416/// *configuration*, not a slowdown event — they land in the catch-all rather than masquerade
417/// as thermal or power throttling.
418/// - GPU_IDLE and NONE set nothing: an idle GPU is not throttling, and narrating idle as a
419/// throttle event would be a confidently-wrong story. (See `ThrottleReasons::any`.)
420fn map_throttle(bits: NvmlThrottle) -> ThrottleReasons {
421 // Bits that map to a specific category, plus the two we intentionally treat as "nothing"
422 // (GPU_IDLE, NONE). Anything outside this set is an unrecognized/future bit → `other`,
423 // so tolerant decoding never silently drops a reason.
424 let categorized = NvmlThrottle::SW_POWER_CAP
425 | NvmlThrottle::SW_THERMAL_SLOWDOWN
426 | NvmlThrottle::HW_THERMAL_SLOWDOWN
427 | NvmlThrottle::HW_SLOWDOWN
428 | NvmlThrottle::HW_POWER_BRAKE_SLOWDOWN
429 | NvmlThrottle::SYNC_BOOST
430 | NvmlThrottle::APPLICATIONS_CLOCKS_SETTING
431 | NvmlThrottle::DISPLAY_CLOCK_SETTING
432 | NvmlThrottle::GPU_IDLE
433 | NvmlThrottle::NONE;
434
435 // `other` covers the two configuration limiters AND any bit we do not know about.
436 let other = bits.intersects(
437 NvmlThrottle::APPLICATIONS_CLOCKS_SETTING | NvmlThrottle::DISPLAY_CLOCK_SETTING,
438 ) || !(bits - categorized).is_empty();
439
440 ThrottleReasons {
441 thermal: bits
442 .intersects(NvmlThrottle::SW_THERMAL_SLOWDOWN | NvmlThrottle::HW_THERMAL_SLOWDOWN),
443 power_cap: bits.contains(NvmlThrottle::SW_POWER_CAP),
444 hw_slowdown: bits
445 .intersects(NvmlThrottle::HW_SLOWDOWN | NvmlThrottle::HW_POWER_BRAKE_SLOWDOWN),
446 sync_boost: bits.contains(NvmlThrottle::SYNC_BOOST),
447 other,
448 }
449}
450
451impl GpuBackend for NvidiaBackend {
452 fn name(&self) -> &'static str {
453 "nvidia"
454 }
455
456 fn devices(&mut self) -> Vec<DeviceId> {
457 self.devs.iter().map(|(_, id)| id.clone()).collect()
458 }
459
460 fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
461 let i = self.index_of(dev)?;
462 let d = self
463 .nvml
464 .device_by_index(i)
465 .map_err(|e| BackendError::Unavailable(e.to_string()))?;
466
467 // Why the process list/columns may be incomplete differs per OS: Linux = WSL2
468 // (detected once at init); Windows = the per-device driver model (§2.7 — WDDM is
469 // where NVML cannot see per-process VRAM; mixed WDDM/TCC multi-GPU boxes exist,
470 // hence per-device rather than per-backend) crossed with whether the PDH fill
471 // (§2.4) actually attributes this device.
472 #[cfg(target_os = "linux")]
473 let process_hint = self.process_hint.clone();
474 #[cfg(target_os = "linux")]
475 let source_caveat = None;
476 #[cfg(target_os = "windows")]
477 let (process_hint, source_caveat) = {
478 #[cfg(feature = "wddm")]
479 let pdh = if !crate::wddm::pdh::shared().engine_object_present() {
480 PdhAttribution::NoPdh
481 } else if self.luid_of(dev).is_some() {
482 PdhAttribution::Available
483 } else {
484 PdhAttribution::NoLuidMatch
485 };
486 #[cfg(not(feature = "wddm"))]
487 let pdh = PdhAttribution::NoPdh;
488 (
489 windows_process_hint(device_driver_model(&d), pdh),
490 windows_source_caveat(pdh),
491 )
492 };
493
494 Ok(StaticInfo {
495 id: dev.clone(),
496 vendor: Vendor::Nvidia,
497 name: opt(d.name()).unwrap_or_else(|| "NVIDIA GPU".into()),
498 backend: "nvidia".into(),
499 // memory_info binds nvmlDeviceGetMemoryInfo_v2 (R510+, early 2022). On a
500 // pre-R510 driver it fails with FailedToLoadSymbol → None via opt(): the
501 // documented driver floor (§2.2) renders as "unavailable", never an error.
502 mem_total_bytes: opt(d.memory_info()).map(|m| m.total),
503 power_limit_mw: opt(d.enforced_power_limit()),
504 max_sm_clock_mhz: opt(d.max_clock_info(Clock::SM)),
505 temp_slowdown_c: opt(d.temperature_threshold(TemperatureThreshold::Slowdown))
506 .map(|t| t as f32),
507 driver_version: opt(self.nvml.sys_driver_version()),
508 process_hint,
509 source_caveat,
510 })
511 }
512
513 fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
514 let i = self.index_of(dev)?;
515 let d = self
516 .nvml
517 .device_by_index(i)
518 .map_err(|e| BackendError::Unavailable(e.to_string()))?;
519
520 // Device-level duty-cycle utilization. MIG-enabled GPUs legitimately return
521 // NOT_SUPPORTED here → None (Linux-only concern: MIG does not exist on Windows).
522 let util = opt(d.utilization_rates());
523 // Pre-R510: FailedToLoadSymbol → None, same floor as static_info.
524 let nvml_mem_used = opt(d.memory_info()).map(|m| m.used);
525 // §2.3: under WDDM, NVML's `used` is the driver's view of a *virtualized* space
526 // (VidMm can page VRAM to system RAM) and can diverge from the OS's number. The
527 // PDH adapter-level Dedicated Usage (the VidMm truth — KB4490156) is the primary
528 // Windows source; the NVML view is the fallback, named as "driver view" by the
529 // static source_caveat. Elsewhere the NVML number IS the device truth.
530 #[cfg(all(target_os = "windows", feature = "wddm"))]
531 let mem_used_bytes = self
532 .luid_of(dev)
533 .and_then(|(h, l)| {
534 crate::wddm::pdh::shared()
535 .snapshot(now_ms())
536 .and_then(|s| crate::wddm::pdh::adapter_bytes(&s.adapter_dedicated, h, l))
537 })
538 .or(nvml_mem_used);
539 #[cfg(not(all(target_os = "windows", feature = "wddm")))]
540 let mem_used_bytes = nvml_mem_used;
541 // Throttle reasons are empirically readable under WDDM — same mapping as Linux.
542 // A failed query (NOT_SUPPORTED, GPU lost, ...) is an unobservable tick → `None`,
543 // NEVER the all-false default: that would assert "not throttling" as a fact this
544 // tick did not observe (§5.4 — the fabricated negative the model change exists
545 // to make unrepresentable).
546 let throttle = opt(d.current_throttle_reasons()).map(map_throttle);
547
548 Ok(DynamicSample {
549 ts_ms: now_ms(),
550 util_pct: util.as_ref().map(|u| u.gpu as f32),
551 // NVML utilization is a whole-device duty-cycle, not an engine headline.
552 util_engine: None,
553 mem_used_bytes,
554 power_mw: opt(d.power_usage()),
555 temp_c: opt(d.temperature(TemperatureSensor::Gpu)).map(|t| t as f32),
556 fan_pct: opt(d.fan_speed(0)).map(|f| f as f32),
557 sm_clock_mhz: opt(d.clock_info(Clock::SM)),
558 mem_clock_mhz: opt(d.clock_info(Clock::Memory)),
559 encoder_pct: opt(d.encoder_utilization()).map(|e| e.utilization as f32),
560 decoder_pct: opt(d.decoder_utilization()).map(|e| e.utilization as f32),
561 throttle,
562 })
563 }
564
565 fn refresh_processes(&mut self, dev: &DeviceId) -> Result<Vec<ProcessSample>, BackendError> {
566 let i = self.index_of(dev)?;
567 let d = self
568 .nvml
569 .device_by_index(i)
570 .map_err(|e| BackendError::Unavailable(e.to_string()))?;
571
572 // PIDs + VRAM from the compute and graphics lists; a PID in both is "C+G".
573 #[cfg(target_os = "linux")]
574 let (compute, graphics) = (
575 d.running_compute_processes().unwrap_or_default(),
576 d.running_graphics_processes().unwrap_or_default(),
577 );
578 // Windows: same lists, plus the §2.2 pre-R510 fallback. nvml-wrapper 0.12 binds
579 // the _v3 symbols (R510+); a pre-2022 driver yields FailedToLoadSymbol, and only
580 // then is the _v2 variant retried once (requires nvml-wrapper's "legacy-functions"
581 // feature) before giving up to an empty list. PID enumeration is NVML's job even
582 // under WDDM — it is the per-process *memory* that WDDM hides (see
583 // `used_gpu_memory_bytes`).
584 #[cfg(target_os = "windows")]
585 let (compute, graphics) = (
586 match d.running_compute_processes() {
587 Ok(v) => v,
588 Err(e) if should_retry_v2(&e) => {
589 d.running_compute_processes_v2().unwrap_or_default()
590 }
591 Err(_) => Vec::new(),
592 },
593 match d.running_graphics_processes() {
594 Ok(v) => v,
595 Err(e) if should_retry_v2(&e) => {
596 d.running_graphics_processes_v2().unwrap_or_default()
597 }
598 Err(_) => Vec::new(),
599 },
600 );
601
602 let mut by_pid: HashMap<u32, ProcessSample> = HashMap::new();
603 for (list, kind) in [
604 (compute, ProcessKind::Compute),
605 (graphics, ProcessKind::Graphics),
606 ] {
607 for p in list {
608 // WDDM (always) / WSL2: VRAM legitimately unavailable → None, never 0;
609 // show the process anyway. See `used_gpu_memory_bytes` for the full story.
610 let mem = used_gpu_memory_bytes(&p.used_gpu_memory);
611 by_pid
612 .entry(p.pid)
613 .and_modify(|e| {
614 e.kind = ProcessKind::Both;
615 if e.mem_bytes.is_none() {
616 e.mem_bytes = mem;
617 }
618 })
619 .or_insert_with(|| ProcessSample {
620 pid: p.pid,
621 name: self.process_name(p.pid),
622 kind,
623 mem_bytes: mem,
624 util_pct: None,
625 cpu_pct: None,
626 container: None,
627 });
628 }
629 }
630
631 // Per-PID utilization samples since our last watermark. NOT_FOUND when nothing ran
632 // is normal; semantics are weak under concurrency (documented NVML limitation), so
633 // these populate a column, never headline numbers. Linux-only: per NVIDIA's own
634 // forum guidance the samples are only meaningful when a single process owns the
635 // GPU, and on Windows the PDH per-pid `GPU Engine` counters are strictly better —
636 // the §2.4 join below fills `util_pct` from the shared PDH snapshot instead, so
637 // this API is deliberately never called there.
638 #[cfg(target_os = "linux")]
639 {
640 let since = self.last_util_ts.get(&i).copied().unwrap_or(0);
641 if let Ok(samples) = d.process_utilization_stats(since) {
642 let mut newest = since;
643 for s in samples {
644 newest = newest.max(s.timestamp);
645 if let Some(p) = by_pid.get_mut(&s.pid) {
646 p.util_pct = Some(s.sm_util as f32);
647 }
648 }
649 self.last_util_ts.insert(i, newest);
650 }
651 }
652
653 // CPU% and container identity come from /proc on Linux (Windows has neither — both
654 // stay None there). The CpuTracker holds per-PID state, so prune it to the PIDs we
655 // still see to keep it from growing across a long session. container_of is stateless.
656 #[cfg(target_os = "linux")]
657 {
658 let live: Vec<u32> = by_pid.keys().copied().collect();
659 for (pid, p) in by_pid.iter_mut() {
660 p.cpu_pct = self.cpu.sample(*pid);
661 p.container = crate::proc_meta::container_of(*pid);
662 }
663 self.cpu.prune(&live);
664 }
665
666 // §2.4: fill the WDDM-hidden columns from the shared PDH snapshot, joined by
667 // (pid, this device's LUID). NVML stays the spine — it knows compute vs graphics —
668 // and PDH supplies what WDDM hides from it: per-process dedicated VRAM
669 // (`GPU Process Memory\Dedicated Usage`; Shared Usage is NEVER folded in) and the
670 // per-pid max-across-engines busy% (the Task-Manager-comparable number, scheduler
671 // duty-cycle). Pids only PDH sees (e.g. dwm.exe when the graphics list misses it)
672 // are appended; their kind is Unknown unless the Compute/Cuda engtype heuristic
673 // (§3.5) upgrades it. A failed LUID match (`luid_of` → None) leaves every column
674 // honestly None — `process_hint` already explains why.
675 #[cfg(all(target_os = "windows", feature = "wddm"))]
676 if let Some((h, l)) = self.luid_of(dev) {
677 if let Some(snap) = crate::wddm::pdh::shared().snapshot(now_ms()) {
678 let util = crate::wddm::pdh::per_pid_util(&snap.engine_util, h, l);
679 let mem = crate::wddm::pdh::per_pid_bytes(&snap.proc_dedicated, h, l);
680 for (pid, p) in by_pid.iter_mut() {
681 if p.mem_bytes.is_none() {
682 p.mem_bytes = mem.get(pid).copied();
683 }
684 if p.util_pct.is_none() {
685 p.util_pct = util.get(pid).map(|u| u.pct as f32);
686 }
687 }
688 let mut pdh_only: Vec<u32> = util
689 .keys()
690 .chain(mem.keys())
691 .copied()
692 .filter(|pid| !by_pid.contains_key(pid))
693 .collect();
694 pdh_only.sort_unstable();
695 pdh_only.dedup();
696 for pid in pdh_only {
697 by_pid.insert(
698 pid,
699 ProcessSample {
700 pid,
701 name: crate::wddm::os_process_name(pid),
702 kind: if util.get(&pid).is_some_and(|u| u.compute_hint) {
703 ProcessKind::Compute
704 } else {
705 ProcessKind::Unknown
706 },
707 mem_bytes: mem.get(&pid).copied(),
708 util_pct: util.get(&pid).map(|u| u.pct as f32),
709 cpu_pct: None,
710 container: None,
711 },
712 );
713 }
714 }
715 }
716
717 Ok(by_pid.into_values().collect())
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use super::{
724 classify_driver_model_err, explicit_lib_path_for, is_wsl, map_throttle, should_retry_v2,
725 used_gpu_memory_bytes, windows_process_hint, windows_source_caveat, DriverModelClass,
726 NvmlError, NvmlThrottle, PdhAttribution, UsedGpuMemory,
727 };
728
729 #[test]
730 fn is_wsl_matches_real_kernel_release_strings() {
731 // Current WSL2 naming and the WSL1-era capital-M variant must both match.
732 assert!(is_wsl("5.15.167.4-microsoft-standard-WSL2"));
733 assert!(is_wsl("4.4.0-19041-Microsoft"));
734 // Regular distro kernels must not.
735 assert!(!is_wsl("6.17.0-35-generic"));
736 assert!(!is_wsl(""));
737 }
738
739 #[test]
740 fn throttle_sw_power_cap_maps_to_power_cap() {
741 // The dominant cause on power-limited consumer parts (e.g. a 4090 Laptop) that rivals
742 // skip — it must read as power_cap and nothing else.
743 let r = map_throttle(NvmlThrottle::SW_POWER_CAP);
744 assert!(r.power_cap);
745 assert!(!r.thermal && !r.hw_slowdown && !r.sync_boost && !r.other);
746 }
747
748 #[test]
749 fn throttle_both_thermal_tiers_map_to_thermal() {
750 // SW thermal (over operating temp) and HW thermal (the 2x brake) both → thermal.
751 assert!(map_throttle(NvmlThrottle::SW_THERMAL_SLOWDOWN).thermal);
752 assert!(map_throttle(NvmlThrottle::HW_THERMAL_SLOWDOWN).thermal);
753 let both =
754 map_throttle(NvmlThrottle::SW_THERMAL_SLOWDOWN | NvmlThrottle::HW_THERMAL_SLOWDOWN);
755 assert!(both.thermal && !both.power_cap && !both.hw_slowdown);
756 }
757
758 #[test]
759 fn throttle_hw_slowdown_and_power_brake_map_to_hw_slowdown() {
760 assert!(map_throttle(NvmlThrottle::HW_SLOWDOWN).hw_slowdown);
761 assert!(map_throttle(NvmlThrottle::HW_POWER_BRAKE_SLOWDOWN).hw_slowdown);
762 let both = map_throttle(NvmlThrottle::HW_SLOWDOWN | NvmlThrottle::HW_POWER_BRAKE_SLOWDOWN);
763 assert!(both.hw_slowdown && !both.thermal && !both.power_cap);
764 }
765
766 #[test]
767 fn throttle_sync_boost_maps_to_sync_boost() {
768 let r = map_throttle(NvmlThrottle::SYNC_BOOST);
769 assert!(r.sync_boost);
770 assert!(!r.thermal && !r.power_cap && !r.hw_slowdown && !r.other);
771 }
772
773 #[test]
774 fn throttle_clock_config_bits_map_to_other() {
775 // Applications-clocks and display-clock settings are real limiters but are user/display
776 // configuration, not a slowdown event — they belong in `other`, never thermal/power.
777 let app = map_throttle(NvmlThrottle::APPLICATIONS_CLOCKS_SETTING);
778 assert!(app.other);
779 assert!(!app.thermal && !app.power_cap && !app.hw_slowdown && !app.sync_boost);
780 let disp = map_throttle(NvmlThrottle::DISPLAY_CLOCK_SETTING);
781 assert!(disp.other);
782 assert!(!disp.thermal && !disp.power_cap && !disp.hw_slowdown && !disp.sync_boost);
783 }
784
785 #[test]
786 fn throttle_unknown_future_bit_maps_to_other() {
787 // A bit NVML adds in a future driver that this build does not recognize must not be
788 // silently dropped — tolerant decoding lands it in `other`. Pick a high bit not used
789 // by any current reason.
790 let future = NvmlThrottle::from_bits_retain(1 << 40);
791 let r = map_throttle(future);
792 assert!(
793 r.other,
794 "unrecognized bit must surface as other, not vanish"
795 );
796 assert!(!r.thermal && !r.power_cap && !r.hw_slowdown && !r.sync_boost);
797 }
798
799 #[test]
800 fn throttle_idle_and_none_set_nothing() {
801 // Idle is not throttling, and NONE is the explicit "clocks unrestricted" sentinel.
802 assert!(!map_throttle(NvmlThrottle::GPU_IDLE).any());
803 assert!(!map_throttle(NvmlThrottle::NONE).any());
804 assert!(!map_throttle(NvmlThrottle::empty()).any());
805 // GPU_IDLE alongside a real reason must not mask that reason.
806 let mixed = map_throttle(NvmlThrottle::GPU_IDLE | NvmlThrottle::SW_POWER_CAP);
807 assert!(mixed.power_cap);
808 }
809
810 #[test]
811 fn throttle_combined_reasons_set_all_relevant() {
812 // A power-capped card that is also thermally limited: both, plus other is clean.
813 let r = map_throttle(NvmlThrottle::SW_POWER_CAP | NvmlThrottle::HW_THERMAL_SLOWDOWN);
814 assert!(r.power_cap && r.thermal);
815 assert!(!r.hw_slowdown && !r.sync_boost && !r.other);
816 }
817
818 // ---- Windows-path logic (pure functions — these tests run on every OS) ----
819
820 #[test]
821 fn lib_path_linux_tries_versioned_soname_first() {
822 // Driver-only installs ship only the versioned soname; the unversioned .so symlink
823 // comes with the CUDA toolkit. This pins the documented loading contract.
824 assert_eq!(explicit_lib_path_for("linux"), Some("libnvidia-ml.so.1"));
825 }
826
827 #[test]
828 fn lib_path_windows_relies_on_system32_default_search() {
829 // nvml.dll sits in System32 on R510+ drivers — already on the default loader
830 // search path. No explicit path, and no probe of the legacy NVSMI directory.
831 assert_eq!(explicit_lib_path_for("windows"), None);
832 }
833
834 #[test]
835 fn v2_process_list_retry_only_on_missing_v3_symbol() {
836 // FailedToLoadSymbol is the pre-R510 "driver too old for _v3" signature — the only
837 // error that earns the one-shot _v2 retry.
838 assert!(should_retry_v2(&NvmlError::FailedToLoadSymbol(
839 "nvmlDeviceGetComputeRunningProcesses_v3".into()
840 )));
841 // Everything else already means "no list this tick" — a retry would just repeat
842 // the same failure (or worse, double-poll a dying device).
843 assert!(!should_retry_v2(&NvmlError::NotSupported));
844 assert!(!should_retry_v2(&NvmlError::DriverNotLoaded));
845 assert!(!should_retry_v2(&NvmlError::GpuLost));
846 assert!(!should_retry_v2(&NvmlError::Unknown));
847 assert!(!should_retry_v2(&NvmlError::UnexpectedVariant(2)));
848 }
849
850 #[test]
851 fn wddm_per_process_memory_unavailable_is_none_never_zero() {
852 // Under WDDM, NVML's usedGpuMemory is ALWAYS Unavailable (the Windows kernel owns
853 // that accounting). The honest mapping is None — a fabricated 0 would render as
854 // "this process uses no VRAM", exactly the lie the trust thesis forbids.
855 assert_eq!(used_gpu_memory_bytes(&UsedGpuMemory::Unavailable), None);
856 // A real reported value (Linux, TCC) passes through untouched.
857 assert_eq!(
858 used_gpu_memory_bytes(&UsedGpuMemory::Used(123_456_789)),
859 Some(123_456_789)
860 );
861 }
862
863 #[test]
864 fn driver_model_hint_wddm_names_only_a_source_that_delivers() {
865 // With the PDH fill live, the hint may (and must) name Windows GPU performance
866 // counters as the per-process source — that claim is now backed by the §2.4 join.
867 let hint = windows_process_hint(DriverModelClass::Wddm, PdhAttribution::Available)
868 .expect("WDDM must come with an explanation");
869 assert!(
870 hint.contains("WDDM") && hint.contains("NVML"),
871 "hint must name the driver model and why NVML is not the source: {hint}"
872 );
873 // A failed LUID↔PCI match is an honest terminal state (§2.5): the hint must say
874 // the data is UNAVAILABLE — never name a source nothing delivers.
875 let hint = windows_process_hint(DriverModelClass::Wddm, PdhAttribution::NoLuidMatch)
876 .expect("a failed match must be explained");
877 assert!(
878 hint.contains("unavailable") && hint.contains("match failed"),
879 "failed match must read as unavailable, not as a working source: {hint}"
880 );
881 // No PDH at all (no WDDM 2.0 GPU/driver): same rule.
882 let hint = windows_process_hint(DriverModelClass::Wddm, PdhAttribution::NoPdh)
883 .expect("missing PDH must be explained");
884 assert!(hint.contains("unavailable"), "{hint}");
885 }
886
887 #[test]
888 fn driver_model_hint_tcc_has_nothing_to_explain() {
889 // TCC (non-display): NVML's per-process accounting works — a hint here would
890 // wrongly disclaim data that is actually present.
891 assert_eq!(
892 windows_process_hint(DriverModelClass::Tcc, PdhAttribution::Available),
893 None
894 );
895 assert_eq!(
896 windows_process_hint(DriverModelClass::Tcc, PdhAttribution::NoPdh),
897 None
898 );
899 }
900
901 #[test]
902 fn driver_model_hint_unknown_model_is_flagged_not_fatal() {
903 // Future MCDM (= 2, missing from nvml-wrapper 0.12's enum) must surface as an
904 // honest "unknown model" hint — never a crash, never silence.
905 let hint =
906 windows_process_hint(DriverModelClass::UnknownVariant, PdhAttribution::Available)
907 .expect("an unknown driver model must be flagged");
908 assert!(
909 hint.contains("unknown"),
910 "hint must say the model is unknown: {hint}"
911 );
912 }
913
914 #[test]
915 fn windows_source_caveat_names_the_real_memory_source() {
916 // PDH attributable: VidMm dedicated usage is primary, driver view the fallback.
917 let c = windows_source_caveat(PdhAttribution::Available).unwrap();
918 assert!(c.contains("VidMm") && c.contains("PDH"), "{c}");
919 // No PDH for this device: the number IS the driver's virtualized view — the
920 // caveat must say so instead of borrowing the VidMm label (§2.3 "driver view").
921 for pdh in [PdhAttribution::NoLuidMatch, PdhAttribution::NoPdh] {
922 let c = windows_source_caveat(pdh).unwrap();
923 assert!(
924 c.contains("driver's view") && !c.contains("PDH"),
925 "fallback caveat must label the driver view, not PDH: {c}"
926 );
927 }
928 }
929
930 #[test]
931 fn driver_model_classification_handles_mcdm_and_query_failure() {
932 // UnexpectedVariant is NVML reporting a model newer than this build (MCDM = 2):
933 // keep the device, flag the unknown model (§2.6).
934 assert_eq!(
935 classify_driver_model_err(&NvmlError::UnexpectedVariant(2)),
936 DriverModelClass::UnknownVariant
937 );
938 // Any other query failure defaults to WDDM — the Windows default driver model,
939 // and the case where a missing hint would leave an empty column unexplained.
940 assert_eq!(
941 classify_driver_model_err(&NvmlError::NotSupported),
942 DriverModelClass::Wddm
943 );
944 assert_eq!(
945 classify_driver_model_err(&NvmlError::Unknown),
946 DriverModelClass::Wddm
947 );
948 }
949}