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}
25
26impl NvmlCollector {
27    /// Initializes NVML. Never fails; check [`NvmlCollector::init_error`] for the reason
28    /// when no GPUs are reported.
29    pub fn new() -> Self {
30        match Nvml::init() {
31            Ok(nvml) => Self {
32                nvml: Some(nvml),
33                init_error: None,
34                last_sample_ts: HashMap::new(),
35            },
36            Err(err) => Self {
37                nvml: None,
38                init_error: Some(err.to_string()),
39                last_sample_ts: HashMap::new(),
40            },
41        }
42    }
43
44    /// The NVML initialization error, if any.
45    pub fn init_error(&self) -> Option<&str> {
46        self.init_error.as_deref()
47    }
48
49    /// Queries every NVIDIA GPU. Individual metrics a device does not support
50    /// are left as `None`.
51    pub fn collect(&mut self) -> Vec<GpuStats> {
52        let Some(nvml) = self.nvml.as_ref() else {
53            return Vec::new();
54        };
55        let count = nvml.device_count().unwrap_or(0);
56        let mut gpus = Vec::with_capacity(count as usize);
57        for index in 0..count {
58            let Ok(device) = nvml.device_by_index(index) else {
59                continue;
60            };
61            let last_ts = self.last_sample_ts.get(&index).copied();
62            let (stats, newest_ts) = collect_device(index, &device, last_ts);
63            if let Some(ts) = newest_ts {
64                self.last_sample_ts.insert(index, ts);
65            }
66            gpus.push(stats);
67        }
68        gpus
69    }
70}
71
72impl Default for NvmlCollector {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78/// Reads one device. Also returns the newest process sample timestamp.
79fn collect_device(
80    index: u32,
81    device: &Device<'_>,
82    last_ts: Option<u64>,
83) -> (GpuStats, Option<u64>) {
84    let utilization = device.utilization_rates().ok();
85    let memory = device.memory_info().ok();
86    let (processes, newest_ts) = collect_processes(device, last_ts);
87    let stats = GpuStats {
88        index,
89        vendor: "NVIDIA".to_owned(),
90        name: device.name().unwrap_or_else(|_| format!("GPU {index}")),
91        utilization_percent: utilization.as_ref().map(|u| u.gpu as f32),
92        memory_utilization_percent: utilization.as_ref().map(|u| u.memory as f32),
93        memory_used_bytes: memory.as_ref().map(|m| m.used),
94        memory_total_bytes: memory.as_ref().map(|m| m.total),
95        temperature_c: device
96            .temperature(TemperatureSensor::Gpu)
97            .ok()
98            .map(|t| t as f32),
99        // NVML reports milliwatts.
100        power_w: device.power_usage().ok().map(|mw| mw as f32 / 1000.0),
101        power_limit_w: device
102            .enforced_power_limit()
103            .ok()
104            .map(|mw| mw as f32 / 1000.0),
105        fan_percent: device.fan_speed(0).ok().map(|p| p as f32),
106        clock_mhz: device.clock_info(Clock::Graphics).ok(),
107        processes,
108    };
109    (stats, newest_ts)
110}
111
112/// Merges compute and graphics process lists with per-process utilization.
113fn collect_processes(device: &Device<'_>, last_ts: Option<u64>) -> (Vec<GpuProcess>, Option<u64>) {
114    // A process can appear in both lists (e.g. CUDA + OpenGL); keep one entry
115    // per pid with the larger memory figure.
116    let mut by_pid: HashMap<u32, GpuProcess> = HashMap::new();
117    let lists = [
118        device.running_compute_processes(),
119        device.running_graphics_processes(),
120    ];
121    for info in lists.into_iter().flat_map(|r| r.unwrap_or_default()) {
122        let memory = used_memory(&info);
123        let entry = by_pid.entry(info.pid).or_insert_with(|| GpuProcess {
124            pid: info.pid,
125            ..Default::default()
126        });
127        entry.memory_bytes = entry.memory_bytes.max(memory);
128    }
129
130    // Utilization samples: keep the newest sample of each pid.
131    let mut newest_ts = last_ts;
132    // NotFound means "no samples since last_ts", which is normal for idle
133    // GPUs; NotSupported is common on older devices. Both just mean no data.
134    let samples = device
135        .process_utilization_stats(last_ts)
136        .unwrap_or_default();
137    let mut latest: HashMap<u32, (u64, u32)> = HashMap::new();
138    for sample in samples {
139        newest_ts = Some(newest_ts.map_or(sample.timestamp, |t| t.max(sample.timestamp)));
140        let slot = latest.entry(sample.pid).or_insert((0, 0));
141        if sample.timestamp >= slot.0 {
142            *slot = (sample.timestamp, sample.sm_util);
143        }
144    }
145    for (pid, (_, sm_util)) in latest {
146        if let Some(process) = by_pid.get_mut(&pid) {
147            process.sm_percent = Some(sm_util as f32);
148        }
149    }
150
151    let mut processes: Vec<GpuProcess> = by_pid.into_values().collect();
152    processes.sort_unstable_by_key(|p| std::cmp::Reverse(p.memory_bytes));
153    (processes, newest_ts)
154}
155
156fn used_memory(info: &NvmlProcessInfo) -> Option<u64> {
157    match info.used_gpu_memory {
158        UsedGpuMemory::Used(bytes) => Some(bytes),
159        UsedGpuMemory::Unavailable => None,
160    }
161}