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}