Skip to main content

corescout_substrate/observation/
thermal.rs

1//! Thermal state.
2//!
3//! | property | value |
4//! |---|---|
5//! | physical fact | the temperature reported by an on-die or on-board sensor |
6//! | source | `/sys/class/thermal/thermal_zoneN/`, `/sys/class/hwmon/hwmonN/tempX_input` |
7//! | sample rate | ~10 Hz; the underlying sensors update on the order of milliseconds and are heavily filtered |
8//! | cost | one file read per sensor |
9//! | perturbation | **Low** |
10//! | uncertainty | +/- 1 C at best, and the reading lags the junction it describes |
11//!
12//! # Why reading a temperature is not free
13//!
14//! On `coretemp` a read is an `rdmsr` of `IA32_THERM_STATUS` **on the target
15//! core**, dispatched by IPI. Observing a core's temperature therefore
16//! interrupts it, and if the core was in a deep C-state, wakes it, which changes
17//! the temperature. On board sensors behind SMBus or PECI the transaction takes
18//! milliseconds of bus time.
19//!
20//! This is the sharpest illustration of the observer effect in the whole system:
21//! a naive implementation polling every core's temperature at 100 Hz would be
22//! preventing those cores from reaching the idle states whose thermal effect it
23//! was trying to measure. Hence a declared ceiling of 10 Hz.
24//!
25//! # Thermal zones are their own entities
26//!
27//! A thermal zone is not a property of a core. It is a distinct physical thing
28//! with its own identity, its own sampling behaviour, and a many-to-many
29//! relationship with the cores it covers: a package sensor covers every core, a
30//! per-core sensor covers one, and the two disagree by design.
31//!
32//! Modelling temperature as a `f64` field on a core would force a choice between
33//! those and throw the rest away. Instead each sensor becomes an entity and is
34//! linked to what it measures by a `ThermalDomain` edge, so a learner can
35//! discover for itself which sensors move together and which lead which.
36
37use crate::observation::source;
38use crate::observation::{
39    BindContext, Perturbation, Sensor, SensorDescriptor, SensorId, SensorOutcome, StateWriter,
40    Uncertainty,
41};
42use corescout_core::error::{Error, Result};
43use corescout_mirror::entity::{keys, Entity, EntityClass};
44use corescout_mirror::relation::{Relation, RelationKind};
45use corescout_mirror::state::{ChannelId, Semantics, Unit};
46use std::path::PathBuf;
47
48/// Divisor from the kernel's millidegree convention to Celsius.
49const MILLIDEGREES: f64 = 1000.0;
50
51struct Target {
52    row: u32,
53    input: PathBuf,
54}
55
56pub struct ThermalSensor {
57    targets: Vec<Target>,
58    channel: Option<ChannelId>,
59}
60
61impl ThermalSensor {
62    pub fn new() -> ThermalSensor {
63        ThermalSensor {
64            targets: Vec::new(),
65            channel: None,
66        }
67    }
68}
69
70impl Default for ThermalSensor {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl Sensor for ThermalSensor {
77    fn descriptor(&self) -> SensorDescriptor {
78        SensorDescriptor {
79            id: SensorId(3),
80            key: "thermal",
81            physical_fact: "temperature at each exposed thermal sensor, in degrees Celsius",
82            source: "/sys/class/thermal and /sys/class/hwmon",
83            max_rate_hz: 10.0,
84            // See the module docs: a coretemp read is an IPI to the core being
85            // measured.
86            perturbation: Perturbation::Low,
87            uncertainty: Uncertainty::absolute(
88                1.0,
89                "on-die thermal diodes are specified to about 1 C and are filtered, so the \
90                 reading lags the junction temperature it describes",
91            ),
92            requires_privilege: false,
93        }
94    }
95
96    fn bind(&mut self, ctx: &mut BindContext<'_>) -> Result<()> {
97        let sys = ctx.substrate().roots.sys.clone();
98        let mut targets = Vec::new();
99
100        // Thermal zones: whole-package and platform sensors.
101        for (index, path) in source::numbered_children(sys.join("class/thermal"), "thermal_zone") {
102            let Some(temp) = path.join("temp").exists().then(|| path.join("temp")) else {
103                continue;
104            };
105            let zone_type = source::string(path.join("type")).unwrap_or_else(|| "zone".to_string());
106            let row = ctx.declare_entity(Entity::new(
107                keys::thermal_zone(&zone_type, index),
108                EntityClass::ThermalZone,
109                Some(index),
110            ));
111            // A zone whose type names the package covers the whole machine.
112            if let Some(machine) = ctx.row_of("machine") {
113                ctx.declare_relation(Relation::new(row, machine, RelationKind::ThermalDomain));
114            }
115            targets.push(Target { row, input: temp });
116        }
117
118        // hwmon: where per-core temperatures actually live on x86.
119        for (_, path) in source::numbered_children(sys.join("class/hwmon"), "hwmon") {
120            let chip = source::string(path.join("name")).unwrap_or_else(|| "hwmon".to_string());
121            for index in 1..=32u32 {
122                let input = path.join(format!("temp{index}_input"));
123                if !input.exists() {
124                    continue;
125                }
126                let label = source::string(path.join(format!("temp{index}_label")))
127                    .unwrap_or_else(|| format!("temp{index}"));
128                let row = ctx.declare_entity(Entity::new(
129                    keys::thermal_zone(&format!("{chip}/{label}"), index),
130                    EntityClass::ThermalZone,
131                    Some(index),
132                ));
133
134                // `coretemp` labels per-core sensors "Core N", where N is the
135                // *kernel core id* within the package. Linking the sensor to the
136                // core it names is the only place the mirror uses a label to
137                // infer structure, and it is done here, once, at bind time,
138                // rather than being left for every consumer to guess at.
139                let linked = label
140                    .strip_prefix("Core ")
141                    .and_then(|n| n.trim().parse::<u32>().ok())
142                    .and_then(|core_id| {
143                        ctx.substrate()
144                            .topology
145                            .physical_cores
146                            .iter()
147                            .find(|c| c.core_id == core_id)
148                            .map(|c| (c.package_id, c.core_id))
149                    })
150                    .and_then(|(package, core)| ctx.row_of(&keys::physical_core(package, core)));
151                let target_row = linked.or_else(|| ctx.row_of("machine"));
152                if let Some(target) = target_row {
153                    ctx.declare_relation(Relation::new(row, target, RelationKind::ThermalDomain));
154                }
155                targets.push(Target { row, input });
156            }
157        }
158
159        if targets.is_empty() {
160            return Err(Error::unsupported(
161                "no thermal sensors are exposed (common in VMs and containers)",
162            ));
163        }
164
165        self.targets = targets;
166        self.channel =
167            Some(ctx.declare_channel("thermal.temperature", Unit::Celsius, Semantics::Instant));
168        Ok(())
169    }
170
171    fn observe(&mut self, out: &mut StateWriter<'_>) -> SensorOutcome {
172        let mut outcome = SensorOutcome::default();
173        let Some(channel) = self.channel else {
174            return outcome;
175        };
176        for target in &self.targets {
177            match source::f64(&target.input) {
178                // The kernel reports millidegrees. Converting here, once, is
179                // exactly the normalisation the plane exists to centralise.
180                Some(millidegrees) => {
181                    out.set(target.row, channel, millidegrees / MILLIDEGREES);
182                    outcome.sample();
183                }
184                None => outcome.error(),
185            }
186        }
187        outcome
188    }
189}