Skip to main content

rigtop_core/
gpu.rs

1//! NVIDIA GPU statistics via NVML.
2//!
3//! `nvml-wrapper` loads `libnvidia-ml` at runtime, so machines without an
4//! NVIDIA driver simply report no GPUs instead of failing to start.
5
6use std::collections::HashMap;
7
8use nvml_wrapper::Nvml;
9use nvml_wrapper::enum_wrappers::device::{Clock, TemperatureSensor};
10use nvml_wrapper::enums::device::UsedGpuMemory;
11use nvml_wrapper::{Device, struct_wrappers::device::ProcessInfo as NvmlProcessInfo};
12
13use crate::snapshot::{GpuProcess, GpuStats};
14
15/// Collects per-GPU and per-process statistics from NVML.
16pub struct NvmlCollector {
17    /// `None` when the library or driver is unavailable.
18    nvml: Option<Nvml>,
19    /// Why NVML could not be initialized, for diagnostics.
20    init_error: Option<String>,
21    /// Newest per-process utilization sample timestamp seen, per GPU index.
22    /// Passing it back to NVML returns only samples newer than the last poll.
23    last_sample_ts: HashMap<u32, u64>,
24    /// Whether to query per-process utilization, the costliest NVML call
25    /// (about 2 ms per GPU). Only the full process table shows it.
26    process_utilization: bool,
27}
28
29impl NvmlCollector {
30    /// Initializes NVML. Never fails; check [`NvmlCollector::init_error`] for the reason
31    /// when no GPUs are reported.
32    pub fn new() -> Self {
33        let (nvml, init_error) = Self::init();
34        Self {
35            nvml,
36            init_error,
37            last_sample_ts: HashMap::new(),
38            process_utilization: true,
39        }
40    }
41
42    /// NVIDIA ships no NVML for macOS (nvml-wrapper would look for `nvml.dll`
43    /// there), so it is not tried, and having no GPUs is not worth a note.
44    #[cfg(target_os = "macos")]
45    fn init() -> (Option<Nvml>, Option<String>) {
46        (None, None)
47    }
48
49    #[cfg(not(target_os = "macos"))]
50    fn init() -> (Option<Nvml>, Option<String>) {
51        match Nvml::init() {
52            Ok(nvml) => (Some(nvml), None),
53            Err(err) => (None, Some(err.to_string())),
54        }
55    }
56
57    /// Turns the per-process utilization query on or off (off while only
58    /// the top processes are wanted, as for an overview).
59    pub fn set_process_utilization(&mut self, enabled: bool) {
60        self.process_utilization = enabled;
61    }
62
63    /// The NVML initialization error, if any.
64    pub fn init_error(&self) -> Option<&str> {
65        self.init_error.as_deref()
66    }
67
68    /// Queries every NVIDIA GPU. Individual metrics a device does not support
69    /// are left as `None`.
70    pub fn collect(&mut self) -> Vec<GpuStats> {
71        let Some(nvml) = self.nvml.as_ref() else {
72            return Vec::new();
73        };
74        let count = nvml.device_count().unwrap_or(0);
75        let mut gpus = Vec::with_capacity(count as usize);
76        for index in 0..count {
77            let Ok(device) = nvml.device_by_index(index) else {
78                continue;
79            };
80            let last_ts = self.last_sample_ts.get(&index).copied();
81            let (stats, newest_ts) =
82                collect_device(index, &device, last_ts, self.process_utilization);
83            if let Some(ts) = newest_ts {
84                self.last_sample_ts.insert(index, ts);
85            }
86            gpus.push(stats);
87        }
88        gpus
89    }
90}
91
92impl Default for NvmlCollector {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// Reads one device. Also returns the newest process sample timestamp.
99fn collect_device(
100    index: u32,
101    device: &Device<'_>,
102    last_ts: Option<u64>,
103    process_utilization: bool,
104) -> (GpuStats, Option<u64>) {
105    let utilization = device.utilization_rates().ok();
106    let memory = device.memory_info().ok();
107    let (processes, newest_ts) = collect_processes(device, last_ts, process_utilization);
108    let stats = GpuStats {
109        index,
110        vendor: "NVIDIA".to_owned(),
111        name: device.name().unwrap_or_else(|_| format!("GPU {index}")),
112        // NVML covers discrete NVIDIA GPUs only.
113        integrated: false,
114        utilization_percent: utilization.as_ref().map(|u| u.gpu as f32),
115        memory_utilization_percent: utilization.as_ref().map(|u| u.memory as f32),
116        memory_used_bytes: memory.as_ref().map(|m| m.used),
117        memory_total_bytes: memory.as_ref().map(|m| m.total),
118        temperature_c: device
119            .temperature(TemperatureSensor::Gpu)
120            .ok()
121            .map(|t| t as f32),
122        // NVML reports milliwatts.
123        power_w: device.power_usage().ok().map(|mw| mw as f32 / 1000.0),
124        power_limit_w: device
125            .enforced_power_limit()
126            .ok()
127            .map(|mw| mw as f32 / 1000.0),
128        fan_percent: device.fan_speed(0).ok().map(|p| p as f32),
129        clock_mhz: device.clock_info(Clock::Graphics).ok(),
130        processes,
131    };
132    (stats, newest_ts)
133}
134
135/// Merges compute and graphics process lists with per-process utilization
136/// (when `with_utilization`).
137fn collect_processes(
138    device: &Device<'_>,
139    last_ts: Option<u64>,
140    with_utilization: bool,
141) -> (Vec<GpuProcess>, Option<u64>) {
142    // A process can appear in both lists (e.g. CUDA + OpenGL); keep one entry
143    // per pid with the larger memory figure.
144    let mut by_pid: HashMap<u32, GpuProcess> = HashMap::new();
145    let lists = [
146        device.running_compute_processes(),
147        device.running_graphics_processes(),
148    ];
149    for info in lists.into_iter().flat_map(|r| r.unwrap_or_default()) {
150        let memory = used_memory(&info);
151        let entry = by_pid.entry(info.pid).or_insert_with(|| GpuProcess {
152            pid: info.pid,
153            ..Default::default()
154        });
155        entry.memory_bytes = entry.memory_bytes.max(memory);
156    }
157
158    // Utilization samples: keep the newest sample of each pid. Skipped when
159    // not wanted or when no process uses the GPU (often a second GPU).
160    let mut newest_ts = last_ts;
161    if !with_utilization || by_pid.is_empty() {
162        let mut processes: Vec<GpuProcess> = by_pid.into_values().collect();
163        processes.sort_unstable_by_key(|p| std::cmp::Reverse(p.memory_bytes));
164        return (processes, newest_ts);
165    }
166    // NotFound means "no samples since last_ts", which is normal for idle
167    // GPUs; NotSupported is common on older devices. Both just mean no data.
168    let samples = device
169        .process_utilization_stats(last_ts)
170        .unwrap_or_default();
171    let mut latest: HashMap<u32, (u64, u32)> = HashMap::new();
172    for sample in samples {
173        newest_ts = Some(newest_ts.map_or(sample.timestamp, |t| t.max(sample.timestamp)));
174        let slot = latest.entry(sample.pid).or_insert((0, 0));
175        if sample.timestamp >= slot.0 {
176            *slot = (sample.timestamp, sample.sm_util);
177        }
178    }
179    for (pid, (_, sm_util)) in latest {
180        if let Some(process) = by_pid.get_mut(&pid) {
181            process.sm_percent = Some(sm_util as f32);
182        }
183    }
184
185    let mut processes: Vec<GpuProcess> = by_pid.into_values().collect();
186    processes.sort_unstable_by_key(|p| std::cmp::Reverse(p.memory_bytes));
187    (processes, newest_ts)
188}
189
190fn used_memory(info: &NvmlProcessInfo) -> Option<u64> {
191    match info.used_gpu_memory {
192        UsedGpuMemory::Used(bytes) => Some(bytes),
193        UsedGpuMemory::Unavailable => None,
194    }
195}