Skip to main content

corescout_substrate/observation/
power.rs

1//! Energy and power limits.
2//!
3//! | property | value |
4//! |---|---|
5//! | physical fact | energy consumed by a power domain since boot, and the limits it is held to |
6//! | source | `/sys/class/powercap/intel-rapl:*/` |
7//! | sample rate | ~100 Hz; RAPL's own counters update roughly every millisecond |
8//! | cost | one file read per domain |
9//! | perturbation | **Negligible**: an MSR read on the local core, no cross-CPU work |
10//! | uncertainty | RAPL is a model, not a wattmeter |
11//!
12//! # Why energy and not power
13//!
14//! RAPL exposes a monotonically increasing energy counter. Power is its
15//! derivative, and computing it requires two readings and an interval, which
16//! makes it a memory-layer quantity by the same rule that keeps rates out of
17//! the mirror. Publishing `energy_uj` and letting a consumer difference it is
18//! also *more accurate* than any power figure the mirror could compute, because
19//! the consumer knows exactly which two samples it differenced.
20//!
21//! # The wrap
22//!
23//! The counter wraps at `max_energy_range_uj`, typically every 60 seconds or so
24//! under load. That value is published as its own channel so a consumer
25//! differencing samples can detect and correct a wrap. Doing the correction here
26//! would require remembering the previous value, which is precisely what the
27//! mirror is not allowed to do.
28//!
29//! # Privilege
30//!
31//! Since the PLATYPUS side-channel work, `energy_uj` is root-readable only on
32//! most distributions: fine-grained energy readings leak information about what
33//! other processes are computing. An unprivileged mirror will find this sensor
34//! present and unreadable, which is reported as inactive rather than as an
35//! error, and is one of the clearest cases where what the machine can know about
36//! itself is deliberately limited.
37
38use crate::observation::source;
39use crate::observation::{
40    BindContext, Perturbation, Sensor, SensorDescriptor, SensorId, SensorOutcome, StateWriter,
41    Uncertainty,
42};
43use corescout_core::error::{Error, Result};
44use corescout_mirror::entity::{keys, Entity, EntityClass};
45use corescout_mirror::relation::{Relation, RelationKind};
46use corescout_mirror::state::{ChannelId, Semantics, Unit};
47use std::path::PathBuf;
48
49struct Target {
50    row: u32,
51    energy: PathBuf,
52    range: PathBuf,
53    limit: PathBuf,
54}
55
56pub struct PowerSensor {
57    targets: Vec<Target>,
58    channel_energy: Option<ChannelId>,
59    channel_range: Option<ChannelId>,
60    channel_limit: Option<ChannelId>,
61}
62
63impl PowerSensor {
64    pub fn new() -> PowerSensor {
65        PowerSensor {
66            targets: Vec::new(),
67            channel_energy: None,
68            channel_range: None,
69            channel_limit: None,
70        }
71    }
72}
73
74impl Default for PowerSensor {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl Sensor for PowerSensor {
81    fn descriptor(&self) -> SensorDescriptor {
82        SensorDescriptor {
83            id: SensorId(4),
84            key: "power",
85            physical_fact: "cumulative energy consumed by each RAPL domain, and the power \
86                            limits currently enforced on it",
87            source: "/sys/class/powercap/intel-rapl:*/",
88            max_rate_hz: 100.0,
89            perturbation: Perturbation::Negligible,
90            uncertainty: Uncertainty::relative(
91                0.05,
92                "RAPL is a firmware energy model derived from activity counters, not a \
93                 measurement at the power rail; it tracks real consumption closely on CPU \
94                 domains and less well on package and DRAM domains",
95            ),
96            requires_privilege: true,
97        }
98    }
99
100    fn bind(&mut self, ctx: &mut BindContext<'_>) -> Result<()> {
101        let powercap = ctx.substrate().roots.sys.join("class/powercap");
102        let mut targets = Vec::new();
103
104        for (dir_name, path) in source::named_children(&powercap, "intel-rapl:") {
105            let energy = path.join("energy_uj");
106            if !energy.exists() {
107                continue;
108            }
109            // Reading once at bind time is how we discover whether we are
110            // allowed to read at all. A permission failure here is the normal
111            // unprivileged case.
112            if source::u64(&energy).is_none() {
113                continue;
114            }
115            let name = source::string(path.join("name")).unwrap_or(dir_name);
116            let row = ctx.declare_entity(Entity::new(
117                keys::power_domain(&name),
118                EntityClass::PowerDomain,
119                None,
120            ));
121            if let Some(machine) = ctx.row_of("machine") {
122                ctx.declare_relation(Relation::new(row, machine, RelationKind::PowerDomain));
123            }
124            targets.push(Target {
125                row,
126                energy,
127                range: path.join("max_energy_range_uj"),
128                limit: path.join("constraint_0_power_limit_uw"),
129            });
130        }
131
132        if targets.is_empty() {
133            return Err(Error::unsupported(
134                "no readable RAPL energy domains (absent on AMD without amd_energy, on most \
135                 VMs, and root-only on distributions that restrict energy_uj)",
136            ));
137        }
138
139        self.targets = targets;
140        self.channel_energy =
141            Some(ctx.declare_channel("power.energy", Unit::Microjoule, Semantics::Cumulative));
142        self.channel_range = Some(ctx.declare_channel(
143            "power.energy_wrap_at",
144            Unit::Microjoule,
145            Semantics::Configured,
146        ));
147        self.channel_limit =
148            Some(ctx.declare_channel("power.limit", Unit::Microwatt, Semantics::Configured));
149        Ok(())
150    }
151
152    fn observe(&mut self, out: &mut StateWriter<'_>) -> SensorOutcome {
153        let mut outcome = SensorOutcome::default();
154        for target in &self.targets {
155            source::sample(
156                out,
157                &mut outcome,
158                target.row,
159                self.channel_energy,
160                &target.energy,
161                true,
162            );
163            source::sample(
164                out,
165                &mut outcome,
166                target.row,
167                self.channel_range,
168                &target.range,
169                false,
170            );
171            source::sample(
172                out,
173                &mut outcome,
174                target.row,
175                self.channel_limit,
176                &target.limit,
177                false,
178            );
179        }
180        outcome
181    }
182}