Skip to main content

joule_profiler_source_nvml/
lib.rs

1//! NVML (NVIDIA Management Library) energy profiling integration for Joule Profiler.
2//!
3//! This module provides energy consumption, VRAM usage and GPU utilization metrics forNVIDIA GPUs using the NVML library.
4
5use std::{
6    collections::HashMap,
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10
11use bitflags::bitflags;
12use futures::{StreamExt, TryFutureExt, future::try_join_all, try_join};
13use joule_profiler_core::{
14    sensor::{Sensor, Sensors},
15    source::MetricReader,
16    types::{Metric, Metrics},
17    unit::{MetricUnit, Unit, UnitPrefix},
18};
19use log::{debug, trace};
20use tokio::task::{JoinHandle, spawn_blocking};
21use tokio_timerfd::Interval;
22use tokio_util::sync::CancellationToken;
23
24use crate::{
25    config::NvmlConfig,
26    counters::{Counter, EnergyCounter, PowerCounter, UtilizationCounter, VramCounter},
27    error::NvmlError,
28    hardware::{NvmlHardware, NvmlWrapperHardware},
29};
30
31pub mod config;
32pub mod counters;
33mod error;
34mod hardware;
35
36const NVML_SOURCE_NAME: &str = "NVML";
37
38const MILLI_JOULE_UNIT: MetricUnit = MetricUnit {
39    prefix: UnitPrefix::Milli,
40    unit: Unit::Joule,
41};
42
43const BYTE_UNIT: MetricUnit = MetricUnit {
44    prefix: UnitPrefix::None,
45    unit: Unit::Byte,
46};
47
48const PERCENT_UNIT: MetricUnit = MetricUnit {
49    prefix: UnitPrefix::None,
50    unit: Unit::Percent,
51};
52
53bitflags! {
54    /// The supports of a device.
55    #[derive(Debug, Clone, Copy)]
56    struct DeviceSupport: u8 {
57        const Energy = 1;
58        const Power = 1 << 1;
59        const Vram = 1 << 2;
60        const Utilization = 1 << 3;
61    }
62}
63
64/// Custom result type for NVML.
65type Result<T> = std::result::Result<T, NvmlError>;
66
67/// Polling task handle and its cancellation token.
68type WorkerHandle = (CancellationToken, JoinHandle<Result<()>>);
69
70#[derive(Debug, Clone, Copy)]
71pub struct Device {
72    /// The index of the device.
73    index: u32,
74
75    /// The supports of the device (e.g., Energy, Power, VRAM).
76    support: DeviceSupport,
77}
78
79/// NVML-based energy profiler for NVIDIA GPUs.
80///
81/// This struct provides an interface to monitor energy consumption of NVIDIA GPUs using
82/// the NVML library.
83/// The NVML hardware can be changed for testing purposes, but the default adapter is the NVML one.
84#[allow(private_interfaces, private_bounds)]
85pub struct Nvml<H: NvmlHardware = NvmlWrapperHardware> {
86    /// Source configuration.
87    config: NvmlConfig,
88
89    /// The hardware instance for interacting with the NVIDIA driver.
90    hardware: Arc<H>,
91
92    /// Map of GPU devices.
93    devices: Arc<Vec<Device>>,
94
95    /// The handle to the polling task and its cancellation token.
96    handle: Option<WorkerHandle>,
97
98    /// The current energy counters.
99    energy_counters: HashMap<u32, EnergyCounter>,
100
101    /// The current vram counters.
102    vram_counters: Arc<Mutex<HashMap<u32, VramCounter>>>,
103
104    /// The current utilization counters.
105    utilization_counters: Arc<Mutex<HashMap<u32, UtilizationCounter>>>,
106
107    /// The current power counters.
108    power_counters: Arc<Mutex<HashMap<u32, PowerCounter>>>,
109}
110
111impl<H: NvmlHardware> Nvml<H> {
112    /// Creates the worker task for power and vram polling at the specified polling interval.
113    pub fn create_worker(
114        hardware: Arc<H>,
115        devices: Arc<Vec<Device>>,
116        power_counters: Arc<Mutex<HashMap<u32, PowerCounter>>>,
117        vram_counters: Arc<Mutex<HashMap<u32, VramCounter>>>,
118        utilization_counters: Arc<Mutex<HashMap<u32, UtilizationCounter>>>,
119        poll_interval: Duration,
120    ) -> Result<WorkerHandle> {
121        let mut ticker = Interval::new_interval(poll_interval)?;
122
123        let cancellation_token = CancellationToken::new();
124        let cancellation_token_clone = cancellation_token.clone();
125
126        let handle = tokio::spawn(async move {
127            debug!("Starting NVML source polling.");
128
129            loop {
130                tokio::select! {
131                    _ = ticker.next() => {
132                        trace!("Polled NVML source.");
133                        Self::read_polled_counters(&hardware, &devices, &power_counters, &vram_counters, &utilization_counters).await?;
134                    }
135
136                    () = cancellation_token.cancelled() => {
137                        debug!("NVML worker stopped.");
138                        break;
139                    }
140                }
141            }
142
143            Ok(())
144        });
145
146        Ok((cancellation_token_clone, handle))
147    }
148
149    /// Reads the power, vram and utilization counters for each processors and updates the current counters.
150    ///
151    /// Each device is read on its own blocking thread, in parallel, since NVML calls
152    /// perform blocking driver ioctls. All the queries applicable to a device (vram,
153    /// utilization, power) are grouped in that single blocking task.
154    async fn read_polled_counters(
155        hardware: &Arc<H>,
156        processors: &Arc<Vec<Device>>,
157        power_counters: &Arc<Mutex<HashMap<u32, PowerCounter>>>,
158        vram_counters: &Arc<Mutex<HashMap<u32, VramCounter>>>,
159        utilization_counters: &Arc<Mutex<HashMap<u32, UtilizationCounter>>>,
160    ) -> Result<()> {
161        let tasks = processors.iter().copied().map(|device| {
162            let hardware = hardware.clone();
163            spawn_blocking(move || {
164                let vram = device
165                    .support
166                    .contains(DeviceSupport::Vram)
167                    .then(|| hardware.get_vram_usage(device));
168                let utilization = device
169                    .support
170                    .contains(DeviceSupport::Utilization)
171                    .then(|| hardware.get_utilization(device));
172                let power = device
173                    .support
174                    .contains(DeviceSupport::Power)
175                    .then(|| hardware.get_power(device));
176                (device.index, vram, utilization, power)
177            })
178        });
179
180        for (index, vram, utilization, power) in try_join_all(tasks).await? {
181            if let Some(vram) = vram {
182                let mut lock = vram_counters.lock().map_err(|_| NvmlError::MutexPoisoned)?;
183                lock.entry(index).or_default().update(vram?);
184            }
185
186            if let Some(utilization) = utilization {
187                let mut lock = utilization_counters
188                    .lock()
189                    .map_err(|_| NvmlError::MutexPoisoned)?;
190                lock.entry(index).or_default().update(utilization?);
191            }
192
193            if let Some(power) = power {
194                let mut lock = power_counters
195                    .lock()
196                    .map_err(|_| NvmlError::MutexPoisoned)?;
197                lock.entry(index).or_default().push(power?);
198            }
199        }
200
201        Ok(())
202    }
203}
204
205impl<H: NvmlHardware> MetricReader for Nvml<H> {
206    type Type = HashMap<u32, Counter>;
207
208    type Error = NvmlError;
209
210    type Config = NvmlConfig;
211
212    /// Creates a new NVML source instance.
213    /// This initializes the NVML hardware with the specified devices specification.
214    fn from_config(config: NvmlConfig) -> Result<Self> {
215        let mut hardware = H::new()?;
216        let devices = hardware.init_devices(config.gpus_spec.as_ref())?;
217
218        Ok(Self {
219            config,
220            hardware: Arc::new(hardware),
221            devices: Arc::new(devices),
222            handle: None,
223            energy_counters: HashMap::default(),
224            power_counters: Arc::default(),
225            utilization_counters: Arc::default(),
226            vram_counters: Arc::default(),
227        })
228    }
229
230    async fn measure(&mut self) -> Result<()> {
231        debug!("NVML measure triggered.");
232
233        let energy_future = try_join_all(
234            self.devices
235                .iter()
236                .filter(|device| device.support.contains(DeviceSupport::Energy))
237                .copied()
238                .map(|device| {
239                    let hardware = self.hardware.clone();
240                    spawn_blocking(move || {
241                        let energy = hardware.get_energy(device);
242                        (device.index, energy)
243                    })
244                }),
245        )
246        .map_err(NvmlError::JoinError);
247
248        let polled_future = Self::read_polled_counters(
249            &self.hardware,
250            &self.devices,
251            &self.power_counters,
252            &self.vram_counters,
253            &self.utilization_counters,
254        );
255
256        let (energy_results, ()) = try_join!(energy_future, polled_future)?;
257
258        for (index, energy) in energy_results {
259            self.energy_counters
260                .entry(index)
261                .or_default()
262                .update(energy?);
263        }
264        Ok(())
265    }
266
267    /// Retrieve the current counters and reset them for the next phase.
268    async fn retrieve(&mut self) -> Result<Self::Type> {
269        debug!("Retrieving NVML counters.");
270        let mut energy_counters = self.energy_counters.clone();
271        for counter in self.energy_counters.values_mut() {
272            counter.reset();
273        }
274
275        let mut lock = self
276            .vram_counters
277            .lock()
278            .map_err(|_| NvmlError::MutexPoisoned)?;
279        let mut vram_counters = lock.clone();
280        for counter in lock.values_mut() {
281            counter.reset();
282        }
283
284        let mut lock = self
285            .utilization_counters
286            .lock()
287            .map_err(|_| NvmlError::MutexPoisoned)?;
288        let mut utilization_counters = lock.clone();
289        for counter in lock.values_mut() {
290            counter.reset();
291        }
292
293        let mut lock = self
294            .power_counters
295            .lock()
296            .map_err(|_| NvmlError::MutexPoisoned)?;
297        let mut power_counters = lock.clone();
298        for counter in lock.values_mut() {
299            counter.reset();
300        }
301
302        let map = self
303            .devices
304            .iter()
305            .map(|device| {
306                let energy = energy_counters.remove(&device.index);
307                let vram = vram_counters.remove(&device.index);
308                let utilization = utilization_counters.remove(&device.index);
309                let power = power_counters.remove(&device.index);
310                let counter = Counter {
311                    energy,
312                    vram,
313                    utilization,
314                    power,
315                };
316                (device.index, counter)
317            })
318            .collect();
319
320        Ok(map)
321    }
322
323    fn get_sensors(&self) -> Result<Sensors> {
324        Ok(self
325            .devices
326            .iter()
327            .flat_map(|device| {
328                vec![
329                    Sensor::new(
330                        format!("GPU-{}-energy", device.index),
331                        MILLI_JOULE_UNIT,
332                        Self::get_name(),
333                    ),
334                    Sensor::new(
335                        format!("GPU-{}-vram_min", device.index),
336                        BYTE_UNIT,
337                        Self::get_name(),
338                    ),
339                    Sensor::new(
340                        format!("GPU-{}-vram_max", device.index),
341                        BYTE_UNIT,
342                        Self::get_name(),
343                    ),
344                    Sensor::new(
345                        format!("GPU-{}-utilization_min", device.index),
346                        PERCENT_UNIT,
347                        Self::get_name(),
348                    ),
349                    Sensor::new(
350                        format!("GPU-{}-utilization_max", device.index),
351                        PERCENT_UNIT,
352                        Self::get_name(),
353                    ),
354                ]
355            })
356            .collect())
357    }
358
359    fn to_metrics(&self, result: Self::Type) -> Result<Metrics> {
360        let metrics = result
361            .into_iter()
362            .flat_map(|(index, counter)| {
363                let mut processor_metrics = Vec::new();
364
365                let energy = counter
366                    .energy
367                    .map_or_else(|| counter.power.map(|c| c.compute_energy()), |c| c.diff());
368
369                if let Some(energy) = energy {
370                    processor_metrics.push(Metric::new(
371                        format!("GPU-{index}-energy"),
372                        energy,
373                        MILLI_JOULE_UNIT,
374                        Self::get_name(),
375                    ));
376                }
377
378                if let Some(vram) = counter.vram
379                    && let Some(min) = vram.min
380                    && let Some(max) = vram.max
381                {
382                    processor_metrics.push(Metric::new(
383                        format!("GPU-{index}-vram_min"),
384                        min,
385                        BYTE_UNIT,
386                        Self::get_name(),
387                    ));
388
389                    processor_metrics.push(Metric::new(
390                        format!("GPU-{index}-vram_max"),
391                        max,
392                        BYTE_UNIT,
393                        Self::get_name(),
394                    ));
395                }
396
397                if let Some(utilization) = counter.utilization
398                    && let Some(min) = utilization.min
399                    && let Some(max) = utilization.max
400                {
401                    processor_metrics.push(Metric::new(
402                        format!("GPU-{index}-utilization_min"),
403                        u64::from(min),
404                        PERCENT_UNIT,
405                        Self::get_name(),
406                    ));
407
408                    processor_metrics.push(Metric::new(
409                        format!("GPU-{index}-utilization_max"),
410                        u64::from(max),
411                        PERCENT_UNIT,
412                        Self::get_name(),
413                    ));
414                }
415
416                Ok::<Metrics, NvmlError>(processor_metrics)
417            })
418            .flatten()
419            .collect();
420        Ok(metrics)
421    }
422
423    /// Creates the polling task if a polling interval has been configured.
424    async fn init(&mut self, _pid: i32) -> Result<()> {
425        self.handle = Some(Self::create_worker(
426            self.hardware.clone(),
427            self.devices.clone(),
428            self.power_counters.clone(),
429            self.vram_counters.clone(),
430            self.utilization_counters.clone(),
431            self.config.poll_interval,
432        )?);
433        debug!("NVML source initialized.");
434        Ok(())
435    }
436
437    /// Joins the polling task if it exists.
438    async fn join(&mut self) -> Result<()> {
439        if let Some((cancellation_token, handle)) = self.handle.take() {
440            debug!("Joining NVML source polling task.");
441            cancellation_token.cancel();
442            handle.await??;
443        }
444        Ok(())
445    }
446
447    fn get_name() -> &'static str {
448        NVML_SOURCE_NAME
449    }
450
451    fn get_id() -> &'static str {
452        "nvml"
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use std::{collections::HashMap, sync::Arc, time::Duration};
459
460    use joule_profiler_core::source::MetricReader;
461    use mockall::predicate;
462    use tokio::time::sleep;
463
464    use crate::{
465        Device, DeviceSupport, Nvml,
466        config::NvmlConfig,
467        counters::{Counter, PowerMeasurement},
468        error::NvmlError,
469        hardware::MockNvmlHardware,
470    };
471
472    fn make_device(index: u32, support: DeviceSupport) -> Device {
473        Device { index, support }
474    }
475
476    fn build_nvml(
477        hardware: MockNvmlHardware,
478        devices: Vec<Device>,
479        poll_interval: Duration,
480    ) -> Nvml<MockNvmlHardware> {
481        Nvml {
482            config: NvmlConfig {
483                poll_interval,
484                gpus_spec: None,
485            },
486            hardware: Arc::new(hardware),
487            devices: Arc::new(devices),
488            handle: None,
489            energy_counters: HashMap::default(),
490            power_counters: Arc::default(),
491            utilization_counters: Arc::default(),
492            vram_counters: Arc::default(),
493        }
494    }
495
496    fn power_measurement(power: u32) -> PowerMeasurement {
497        PowerMeasurement {
498            timestamp: 0,
499            power,
500        }
501    }
502
503    #[tokio::test]
504    async fn measure_reads_energy_for_energy_capable_device() {
505        let device = make_device(0, DeviceSupport::Energy);
506
507        let mut hw = MockNvmlHardware::default();
508        hw.expect_get_energy()
509            .with(predicate::function(move |d: &Device| {
510                d.index == device.index
511            }))
512            .once()
513            .returning(|_| Ok(42_000));
514
515        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
516        nvml.measure().await.unwrap();
517
518        assert!(nvml.energy_counters.contains_key(&0));
519    }
520
521    #[tokio::test]
522    async fn measure_reads_power_for_power_only_device() {
523        let device = make_device(0, DeviceSupport::Power);
524
525        let mut hw = MockNvmlHardware::default();
526        hw.expect_get_power()
527            .with(predicate::function(move |d: &Device| {
528                d.index == device.index
529            }))
530            .once()
531            .returning(|_| Ok(power_measurement(150_000)));
532
533        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
534        nvml.measure().await.unwrap();
535
536        assert!(nvml.power_counters.lock().unwrap().contains_key(&0));
537    }
538
539    #[tokio::test]
540    async fn measure_reads_vram_for_vram_capable_device() {
541        let device = make_device(0, DeviceSupport::Vram);
542
543        let mut hw = MockNvmlHardware::default();
544        hw.expect_get_vram_usage()
545            .with(predicate::function(move |d: &Device| {
546                d.index == device.index
547            }))
548            .once()
549            .returning(|_| Ok(8_000_000_000));
550
551        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
552        nvml.measure().await.unwrap();
553
554        assert!(nvml.vram_counters.lock().unwrap().contains_key(&0));
555    }
556
557    #[tokio::test]
558    async fn measure_reads_utilization_for_utilization_capable_device() {
559        let device = make_device(0, DeviceSupport::Utilization);
560
561        let mut hw = MockNvmlHardware::default();
562        hw.expect_get_utilization()
563            .with(predicate::function(|d: &Device| d.index == 0))
564            .once()
565            .returning(|_| Ok(42));
566
567        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
568        nvml.measure().await.unwrap();
569
570        assert!(nvml.utilization_counters.lock().unwrap().contains_key(&0));
571    }
572
573    #[tokio::test]
574    async fn measure_skips_energy_and_power_for_vram_only_device() {
575        let device = make_device(0, DeviceSupport::Vram);
576
577        let mut hw = MockNvmlHardware::default();
578        hw.expect_get_energy().never();
579        hw.expect_get_power().never();
580        hw.expect_get_vram_usage()
581            .once()
582            .returning(|_| Ok(1_000_000));
583
584        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
585        nvml.measure().await.unwrap();
586
587        assert!(nvml.energy_counters.is_empty());
588        assert!(nvml.power_counters.lock().unwrap().is_empty());
589    }
590
591    #[tokio::test]
592    async fn measure_propagates_energy_error() {
593        let device = make_device(0, DeviceSupport::Energy);
594
595        let mut hw = MockNvmlHardware::default();
596        hw.expect_get_energy()
597            .once()
598            .returning(|_| Err(NvmlError::NoPermission));
599
600        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
601        assert!(nvml.measure().await.is_err());
602    }
603
604    #[tokio::test]
605    async fn measure_propagates_power_error() {
606        let device = make_device(0, DeviceSupport::Power);
607
608        let mut hw = MockNvmlHardware::default();
609        hw.expect_get_power()
610            .once()
611            .returning(|_| Err(NvmlError::NoPermission));
612
613        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
614        assert!(nvml.measure().await.is_err());
615    }
616
617    #[tokio::test]
618    async fn retrieve_returns_counters_and_resets_them() {
619        let device = make_device(0, DeviceSupport::Energy | DeviceSupport::Vram);
620
621        let mut hw = MockNvmlHardware::default();
622        hw.expect_get_energy().returning(|_| Ok(10_000));
623        hw.expect_get_vram_usage().returning(|_| Ok(1_000_000));
624
625        let mut nvml = build_nvml(hw, vec![device], Duration::from_secs(1));
626        nvml.measure().await.unwrap();
627        nvml.measure().await.unwrap();
628
629        let result = nvml.retrieve().await.unwrap();
630        assert!(result.contains_key(&0));
631
632        let result2 = nvml.retrieve().await.unwrap();
633        let counter: &Counter = result2.get(&0).unwrap();
634        assert!(
635            counter
636                .energy
637                .as_ref()
638                .and_then(super::counters::EnergyCounter::diff)
639                .is_none_or(|v| v == 0)
640        );
641    }
642
643    #[tokio::test]
644    async fn retrieve_includes_entry_for_every_device() {
645        let devices = vec![
646            make_device(0, DeviceSupport::Energy),
647            make_device(1, DeviceSupport::Power),
648            make_device(2, DeviceSupport::Vram),
649        ];
650
651        let mut hw = MockNvmlHardware::default();
652        hw.expect_get_energy().returning(|_| Ok(1_000));
653        hw.expect_get_power()
654            .returning(|_| Ok(power_measurement(5_000)));
655        hw.expect_get_vram_usage().returning(|_| Ok(1_000_000));
656
657        let mut nvml = build_nvml(hw, devices, Duration::from_secs(1));
658        nvml.measure().await.unwrap();
659
660        let result = nvml.retrieve().await.unwrap();
661        assert_eq!(result.len(), 3);
662        for index in [0, 1, 2] {
663            assert!(result.contains_key(&index));
664        }
665    }
666
667    #[tokio::test]
668    async fn worker_polls_counters_and_can_be_cancelled() {
669        let device = make_device(
670            0,
671            DeviceSupport::Power | DeviceSupport::Vram | DeviceSupport::Utilization,
672        );
673
674        let mut hw = MockNvmlHardware::default();
675        hw.expect_get_power()
676            .returning(|_| Ok(power_measurement(5_000)));
677        hw.expect_get_vram_usage().returning(|_| Ok(1_000_000));
678        hw.expect_get_utilization().returning(|_| Ok(50));
679
680        let mut nvml = build_nvml(hw, vec![device], Duration::from_millis(20));
681        nvml.init(0).await.unwrap();
682
683        sleep(Duration::from_millis(80)).await;
684
685        nvml.join().await.unwrap();
686
687        assert!(nvml.power_counters.lock().unwrap().contains_key(&0));
688    }
689}