Skip to main content

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}