Skip to main content

rigtop_core/linux/
rapl.rs

1//! CPU package power from RAPL energy counters in `/sys/class/powercap`.
2//!
3//! The kernel exposes cumulative energy (`energy_uj`, microjoules) per
4//! package zone; power is the difference between two samples divided by the
5//! elapsed time. AMD CPUs use the same `intel-rapl:N` zone names.
6//!
7//! Some distributions restrict `energy_uj` to root (it can leak information
8//! through power side channels); in that case no power is reported.
9
10use std::path::{Path, PathBuf};
11use std::time::Instant;
12
13use super::{SysRoots, read_trimmed, read_u64, sorted_entries};
14
15/// Tracks RAPL counters between samples to turn energy into power.
16#[derive(Debug, Default)]
17pub struct RaplMeter {
18    /// Package zones discovered on the first sample.
19    zones: Option<Vec<Zone>>,
20    /// Time of the previous sample.
21    last_sample: Option<Instant>,
22}
23
24#[derive(Debug)]
25struct Zone {
26    /// Path to `energy_uj`.
27    energy_path: PathBuf,
28    /// Counter wrap-around value (`max_energy_range_uj`).
29    max_range_uj: u64,
30    /// Counter value at the previous sample.
31    last_uj: Option<u64>,
32}
33
34impl RaplMeter {
35    /// Creates a meter. Zones are discovered lazily on the first sample.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Takes a sample and returns the average package power since the
41    /// previous call, summed over all packages.
42    ///
43    /// Returns `None` on the first call, when no readable zone exists, or
44    /// when samples are too close together to be meaningful.
45    pub fn sample(&mut self, roots: &SysRoots, now: Instant) -> Option<f32> {
46        let zones = self.zones.get_or_insert_with(|| discover_zones(roots));
47        if zones.is_empty() {
48            return None;
49        }
50        let elapsed = self
51            .last_sample
52            .map(|prev| now.saturating_duration_since(prev).as_secs_f64());
53        self.last_sample = Some(now);
54
55        let mut total_w = 0.0;
56        let mut complete = true;
57        for zone in zones.iter_mut() {
58            let current = read_u64(&zone.energy_path);
59            match (zone.last_uj, current, elapsed) {
60                (Some(prev), Some(cur), Some(secs)) => {
61                    match power_from_delta(prev, cur, zone.max_range_uj, secs) {
62                        Some(w) => total_w += w,
63                        None => complete = false,
64                    }
65                }
66                _ => complete = false,
67            }
68            zone.last_uj = current;
69        }
70        complete.then_some(total_w as f32)
71    }
72}
73
74/// Finds top-level package zones (`intel-rapl:0`, `intel-rapl:1`, ...).
75/// Sub-zones such as `intel-rapl:0:0` (core, uncore) are already included in
76/// their package and are skipped to avoid double counting.
77fn discover_zones(roots: &SysRoots) -> Vec<Zone> {
78    let class_dir = roots.sys.join("class/powercap");
79    sorted_entries(&class_dir)
80        .into_iter()
81        .filter(|entry| {
82            entry.matches(':').count() == 1 && entry.ends_with(|c: char| c.is_ascii_digit())
83        })
84        .map(|entry| class_dir.join(entry))
85        .filter(|dir| is_package_zone(dir))
86        .filter_map(|dir| {
87            let energy_path = dir.join("energy_uj");
88            // Probe readability now so that a permission error is detected once.
89            read_u64(&energy_path)?;
90            Some(Zone {
91                max_range_uj: read_u64(&dir.join("max_energy_range_uj")).unwrap_or(0),
92                energy_path,
93                last_uj: None,
94            })
95        })
96        .collect()
97}
98
99fn is_package_zone(dir: &Path) -> bool {
100    read_trimmed(&dir.join("name")).is_some_and(|name| name.starts_with("package"))
101}
102
103/// Converts two energy counter readings into watts, handling one wrap-around.
104///
105/// Returns `None` if the interval is too short or the counter went backwards
106/// without a known wrap value.
107pub(crate) fn power_from_delta(
108    prev_uj: u64,
109    cur_uj: u64,
110    max_range_uj: u64,
111    secs: f64,
112) -> Option<f64> {
113    // Below ~10 ms the counter resolution dominates the result.
114    if secs < 0.01 {
115        return None;
116    }
117    let delta = if cur_uj >= prev_uj {
118        cur_uj - prev_uj
119    } else if max_range_uj > prev_uj {
120        // The counter wrapped past `max_range_uj` back to zero.
121        (max_range_uj - prev_uj) + cur_uj
122    } else {
123        return None;
124    };
125    Some(delta as f64 / 1_000_000.0 / secs)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::linux::testutil::write;
132    use std::time::Duration;
133
134    #[test]
135    fn power_from_plain_delta() {
136        // 50 J over 2 s = 25 W.
137        let w = power_from_delta(1_000_000, 51_000_000, 0, 2.0).unwrap();
138        assert!((w - 25.0).abs() < 1e-9);
139    }
140
141    #[test]
142    fn power_across_wrap_around() {
143        // 10 J before the wrap + 5 J after, over 1 s.
144        let w = power_from_delta(90_000_000, 5_000_000, 100_000_000, 1.0).unwrap();
145        assert!((w - 15.0).abs() < 1e-9);
146    }
147
148    #[test]
149    fn power_rejects_tiny_interval_and_unknown_wrap() {
150        assert!(power_from_delta(0, 10, 0, 0.001).is_none());
151        assert!(power_from_delta(10, 5, 0, 1.0).is_none());
152    }
153
154    #[test]
155    fn meter_sums_packages_and_skips_subzones() {
156        let tmp = tempfile::tempdir().unwrap();
157        let root = tmp.path();
158        write(root, "sys/class/powercap/intel-rapl:0/name", "package-0\n");
159        write(
160            root,
161            "sys/class/powercap/intel-rapl:0/energy_uj",
162            "1000000\n",
163        );
164        write(
165            root,
166            "sys/class/powercap/intel-rapl:0/max_energy_range_uj",
167            "65532610987\n",
168        );
169        // A core sub-zone that must not be added on top of its package.
170        write(root, "sys/class/powercap/intel-rapl:0:0/name", "core\n");
171        write(
172            root,
173            "sys/class/powercap/intel-rapl:0:0/energy_uj",
174            "500000\n",
175        );
176        let roots = SysRoots {
177            sys: root.join("sys"),
178            proc: root.join("proc"),
179        };
180
181        let mut meter = RaplMeter::new();
182        let t0 = Instant::now();
183        assert_eq!(meter.sample(&roots, t0), None);
184
185        write(
186            root,
187            "sys/class/powercap/intel-rapl:0/energy_uj",
188            "31000000\n",
189        );
190        write(
191            root,
192            "sys/class/powercap/intel-rapl:0:0/energy_uj",
193            "99000000\n",
194        );
195        let w = meter.sample(&roots, t0 + Duration::from_secs(1)).unwrap();
196        assert!((w - 30.0).abs() < 1e-3);
197    }
198}