monitrs_core/model/cpu.rs
1//! CPU and load metrics.
2//!
3//! §8.3 fixes the semantics: *system* CPU is aggregate machine usage in
4//! `0..=100`, while *process* CPU defaults to "one core = 100%" and may exceed
5//! 100% for a multi-threaded process.
6
7use crate::model::MetricState;
8use crate::units::Percent;
9
10/// Aggregate or per-core CPU utilization.
11#[derive(Clone, Copy, Debug, PartialEq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize))]
13pub struct CpuUsage {
14 /// Non-idle time as a share of elapsed time, in `0..=100`.
15 pub busy: Percent,
16 /// The `/proc/stat`-style split, where the platform exposes it.
17 pub breakdown: MetricState<CpuBreakdown>,
18}
19
20impl CpuUsage {
21 /// Builds a usage value with no breakdown available.
22 #[must_use]
23 pub const fn plain(busy: Percent) -> Self {
24 Self {
25 busy,
26 breakdown: MetricState::Unsupported,
27 }
28 }
29}
30
31/// A cgroup CPU quota: how much CPU time the group may use per period.
32///
33/// Constructed only from a period that can produce a meaningful ratio, so there is no
34/// representable quota that divides by zero or yields a non-finite core count. An
35/// *unlimited* group is not a `CpuQuota` at all — it is
36/// [`MetricState::Unsupported`] on [`CpuSnapshot::cgroup_quota`], mirroring how
37/// `memory.max` reading `max` becomes unsupported rather than `u64::MAX`.
38///
39/// Both raw figures are kept, not just the derived core count: someone debugging why
40/// their container is being throttled wants to see the `100000 200000` they configured,
41/// and deriving it back from `2.0` would lose the period.
42#[derive(Clone, Copy, Debug, PartialEq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct CpuQuota {
45 /// Microseconds of CPU time allowed per period.
46 quota_us: u64,
47 /// The accounting period in microseconds.
48 period_us: u64,
49}
50
51impl CpuQuota {
52 /// Builds a quota, or `None` when it could not describe a real ceiling.
53 ///
54 /// Rejects a zero period — division by zero — and a ratio that is not finite. A zero
55 /// *quota* is accepted: a group allowed no CPU time at all is a real, if hostile,
56 /// configuration, and reporting it as absent would hide it.
57 #[must_use]
58 pub fn new(quota_us: u64, period_us: u64) -> Option<Self> {
59 if period_us == 0 {
60 return None;
61 }
62 let quota = Self {
63 quota_us,
64 period_us,
65 };
66 quota.cores().is_finite().then_some(quota)
67 }
68
69 /// The ceiling as a number of CPUs, e.g. `1.5`.
70 #[must_use]
71 pub fn cores(&self) -> f32 {
72 // Narrowing to f32 for a figure displayed with one decimal; the ratio of two
73 // microsecond counts cannot exceed f32's range in any real configuration.
74 #[allow(clippy::cast_precision_loss)]
75 let cores = self.quota_us as f64 / self.period_us as f64;
76 #[allow(clippy::cast_possible_truncation)]
77 let cores = cores as f32;
78 cores
79 }
80
81 /// Microseconds of CPU time allowed per period, as configured.
82 #[must_use]
83 pub const fn quota_us(&self) -> u64 {
84 self.quota_us
85 }
86
87 /// The accounting period in microseconds, as configured.
88 #[must_use]
89 pub const fn period_us(&self) -> u64 {
90 self.period_us
91 }
92}
93
94/// A class of logical CPUs that differ in kind, not just in load.
95///
96/// Apple Silicon has performance and efficiency cores; big.LITTLE ARM machines have
97/// the same split under other names. It matters for reading a per-core view: four
98/// efficiency cores at 90% and four performance cores idle is a machine doing very
99/// little, and the opposite is a machine working hard — the same eight numbers.
100///
101/// The name comes from the platform (`hw.perflevelN.name` on macOS) rather than from
102/// a table here, because inventing the vocabulary would mean guessing at hardware
103/// this code has never seen.
104#[derive(Clone, Debug, Eq, PartialEq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub struct CoreClass {
107 /// What the platform calls it, e.g. `Performance` or `Efficiency`.
108 pub name: Box<str>,
109 /// The logical CPUs in this class, as indices into
110 /// [`CpuSnapshot::per_core`].
111 ///
112 /// Indices rather than a count, because a renderer needs to know *which* cores
113 /// they are to colour or group them, and a count would force it to assume the
114 /// classes are contiguous.
115 pub logical: Vec<u16>,
116 /// Physical cores in this class, where the platform reports it separately.
117 pub physical_count: Option<u16>,
118}
119
120impl CoreClass {
121 /// How many logical CPUs this class holds.
122 #[must_use]
123 pub fn len(&self) -> usize {
124 self.logical.len()
125 }
126
127 /// Whether the class holds no CPUs, which a platform should never report.
128 #[must_use]
129 pub fn is_empty(&self) -> bool {
130 self.logical.is_empty()
131 }
132}
133
134/// The per-state split of CPU time.
135///
136/// macOS exposes only `user`, `system`, `nice`, and `idle`; the Linux-only
137/// fields are [`MetricState::Unsupported`] there rather than zero (§4).
138#[derive(Clone, Copy, Debug, PartialEq)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize))]
140pub struct CpuBreakdown {
141 /// Time in user mode.
142 pub user: Percent,
143 /// Time in kernel mode.
144 pub system: Percent,
145 /// Time in low-priority user mode.
146 pub nice: Percent,
147 /// Idle time.
148 pub idle: Percent,
149 /// Time waiting on I/O. Linux only.
150 pub iowait: MetricState<Percent>,
151 /// Time servicing hardware interrupts. Linux only.
152 pub irq: MetricState<Percent>,
153 /// Time servicing soft interrupts. Linux only.
154 pub softirq: MetricState<Percent>,
155 /// Time stolen by the hypervisor. Linux only, and the most useful signal
156 /// that a VM is oversubscribed.
157 pub steal: MetricState<Percent>,
158}
159
160/// How process CPU percentages are scaled (§8.3).
161#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
163#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
164pub enum CpuNormalization {
165 /// One core = 100%. A process using four cores fully reads 400%.
166 ///
167 /// The default, matching `top` and `htop`.
168 #[default]
169 Core,
170 /// The whole machine = 100%. A process using four of eight cores reads 50%.
171 Machine,
172}
173
174impl CpuNormalization {
175 /// The documentation string shown in help and `docs/metrics.md` (§8.3).
176 #[must_use]
177 pub const fn description(self) -> &'static str {
178 match self {
179 Self::Core => "one core = 100%, so a multi-threaded process may exceed 100%",
180 Self::Machine => "the whole machine = 100%, so no process exceeds 100%",
181 }
182 }
183
184 /// Converts a core-normalized percentage into this convention.
185 ///
186 /// Returns `None` when `logical_cpus` is zero, because there is no defined
187 /// machine share to scale against.
188 #[must_use]
189 pub fn apply(self, core_normalized: Percent, logical_cpus: u16) -> Option<Percent> {
190 match self {
191 Self::Core => Some(core_normalized),
192 Self::Machine => {
193 if logical_cpus == 0 {
194 return None;
195 }
196 Percent::new(core_normalized.value() / f32::from(logical_cpus))
197 }
198 }
199 }
200}
201
202/// System-wide CPU state.
203#[derive(Clone, Debug, PartialEq)]
204#[cfg_attr(feature = "serde", derive(serde::Serialize))]
205pub struct CpuSnapshot {
206 /// Logical CPU count, including SMT siblings. Always known.
207 pub logical_count: u16,
208 /// Physical core count, where the platform reports it.
209 pub physical_count: MetricState<u16>,
210 /// Aggregate machine utilization, `0..=100` (§8.3).
211 pub total: MetricState<CpuUsage>,
212 /// Per-logical-CPU utilization, in stable index order.
213 pub per_core: MetricState<Vec<CpuUsage>>,
214 /// Current clock, where reported.
215 pub frequency_mhz: MetricState<u64>,
216 /// The CPU ceiling a cgroup imposes, **beside** the host's CPU count.
217 ///
218 /// §9.2 requires a container limit to be reported separately from the host total, so
219 /// `logical_count` stays the machine's real CPU count and this is the ceiling that
220 /// actually applies to the processes in it. A container limited to 1.5 CPUs on a
221 /// 64-CPU host is not "2% of the machine"; it is a hard wall a process will be
222 /// throttled against, and a monitor that showed only the 64 would be describing a
223 /// machine the user does not have.
224 ///
225 /// [`MetricState::Unsupported`] where no quota is configured — `cpu.max` reading
226 /// `max` is *not* a very large number, it is the absence of a limit — and on every
227 /// platform without cgroups.
228 pub cgroup_quota: MetricState<CpuQuota>,
229 /// The machine's core classes, where the platform names them.
230 ///
231 /// Empty where there is one class or none is reported, which is the honest
232 /// answer for a homogeneous machine — an empty list is not "unknown", it is
233 /// "there is nothing to distinguish". `MetricState` is deliberately not used:
234 /// this is topology, fixed for the life of the machine, and a topology that
235 /// could be `WarmingUp` would invite a renderer to wait for it.
236 pub core_classes: Vec<CoreClass>,
237}
238
239impl CpuSnapshot {
240 /// The number of CPUs that actually applies to processes here.
241 ///
242 /// The cgroup quota where one is configured and below the host's CPU count, the host
243 /// count otherwise. This is the divisor a load average should be read against and the
244 /// ceiling a per-core view is bounded by — the exact counterpart of
245 /// [`MemorySnapshot::effective_limit_bytes`](crate::model::MemorySnapshot::effective_limit_bytes),
246 /// and required by §9.2 for the same reason.
247 ///
248 /// A quota *above* the host count is ignored rather than reported: a group allowed
249 /// more CPU than the machine has is a configuration artefact, not a ceiling, and
250 /// dividing a load average by it would understate the pressure.
251 ///
252 /// A *stale* reading counts here, which is the one place this crate deliberately
253 /// breaks [`MetricState::fresh`]'s "use fresh values for calculations" rule. A limit
254 /// is configuration, not a measurement: if the last successful read said 1.5 CPUs and
255 /// this tick's read failed, the group is still limited to 1.5 CPUs, and falling back
256 /// to the host's 64 would present a machine 42 times larger than the one the process
257 /// is actually being throttled against. Keeping the retained value is wrong only if
258 /// the limit changed in the last few seconds, and the row is marked stale either way.
259 #[must_use]
260 pub fn effective_cores(&self) -> f32 {
261 let host = f32::from(self.logical_count);
262 match self.cgroup_quota.displayable() {
263 Some((quota, _)) if quota.cores() > 0.0 && quota.cores() < host => quota.cores(),
264 _ => host,
265 }
266 }
267
268 /// Whether a cgroup quota, rather than the hardware, is the ceiling here.
269 ///
270 /// What a renderer checks before showing the host CPU count unqualified. Stale
271 /// counts, for the reason [`Self::effective_cores`] gives.
272 #[must_use]
273 pub fn is_cpu_limited(&self) -> bool {
274 self.cgroup_quota.displayable().is_some_and(|(quota, _)| {
275 quota.cores() > 0.0 && quota.cores() < f32::from(self.logical_count)
276 })
277 }
278
279 /// A snapshot with no measurements yet, for the first frame.
280 #[must_use]
281 pub const fn warming_up(logical_count: u16) -> Self {
282 Self {
283 logical_count,
284 physical_count: MetricState::WarmingUp,
285 total: MetricState::WarmingUp,
286 per_core: MetricState::WarmingUp,
287 frequency_mhz: MetricState::WarmingUp,
288 cgroup_quota: MetricState::WarmingUp,
289 // Empty rather than warming up: topology is not measured, it is read
290 // once, and a collector that knows of no classes is reporting a fact.
291 core_classes: Vec::new(),
292 }
293 }
294}
295
296/// Load averages, which are run-queue lengths rather than percentages.
297#[derive(Clone, Copy, Debug, PartialEq)]
298#[cfg_attr(feature = "serde", derive(serde::Serialize))]
299pub struct LoadSnapshot {
300 /// One-minute average.
301 pub one: f32,
302 /// Five-minute average.
303 pub five: f32,
304 /// Fifteen-minute average.
305 pub fifteen: f32,
306}
307
308impl LoadSnapshot {
309 /// The one-minute load expressed per logical CPU.
310 ///
311 /// This is the only form in which load can be compared across machines, and
312 /// it is what the `load high relative to logical CPU count` rule uses
313 /// (§11.2). Returns `None` when the CPU count is unknown.
314 #[must_use]
315 pub fn per_cpu(&self, logical_cpus: u16) -> Option<f32> {
316 if logical_cpus == 0 {
317 return None;
318 }
319 let value = self.one / f32::from(logical_cpus);
320 value.is_finite().then_some(value)
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use core::time::Duration;
327
328 use super::*;
329 use crate::model::UnavailableReason;
330
331 /// Whether two core counts are the same figure.
332 ///
333 /// `assert_eq!` on an `f32` is a clippy error and rightly so, but these are exact
334 /// integers-as-floats: the host count comes from a `u16` and the quotas below divide
335 /// exactly. An epsilon comparison states that plainly.
336 fn same(left: f32, right: f32) -> bool {
337 (left - right).abs() < f32::EPSILON
338 }
339
340 #[test]
341 fn a_cgroup_quota_below_the_host_count_becomes_the_effective_ceiling() {
342 // §9.2: the host's CPUs and the group's ceiling are both observable, and the
343 // ceiling is the one a load average should be read against.
344 let mut cpu = CpuSnapshot::warming_up(64);
345 assert!(same(cpu.effective_cores(), 64.0));
346 assert!(!cpu.is_cpu_limited());
347
348 cpu.cgroup_quota = MetricState::Available(CpuQuota::new(150_000, 100_000).expect("1.5"));
349 assert!(same(cpu.effective_cores(), 1.5));
350 assert!(cpu.is_cpu_limited());
351 assert_eq!(cpu.logical_count, 64, "the host count is untouched");
352 }
353
354 #[test]
355 fn a_quota_above_the_host_count_is_not_a_ceiling() {
356 // A group allowed 128 CPUs on a 64-CPU machine is a configuration artefact.
357 // Reporting it as the ceiling would halve every load figure read against it.
358 let mut cpu = CpuSnapshot::warming_up(64);
359 cpu.cgroup_quota = MetricState::Available(CpuQuota::new(12_800_000, 100_000).expect("128"));
360 assert!(same(cpu.effective_cores(), 64.0));
361 assert!(!cpu.is_cpu_limited());
362 }
363
364 #[test]
365 fn an_unavailable_quota_leaves_the_host_count_as_the_ceiling() {
366 // Every kind of nothing, because §4's states must not each need their own
367 // caller-side special case. Unsupported is the ordinary case off Linux.
368 let mut cpu = CpuSnapshot::warming_up(8);
369 for state in [
370 MetricState::Unsupported,
371 MetricState::WarmingUp,
372 MetricState::PermissionDenied,
373 MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed),
374 ] {
375 cpu.cgroup_quota = state;
376 assert!(same(cpu.effective_cores(), 8.0), "{state:?}");
377 assert!(!cpu.is_cpu_limited(), "{state:?}");
378 }
379 }
380
381 #[test]
382 fn a_stale_quota_still_bounds_the_machine() {
383 // A limit is configuration, not a measurement: a reading a minute old is still
384 // the wall processes are hitting, and falling back to the host count would
385 // silently widen the machine.
386 let mut cpu = CpuSnapshot::warming_up(16);
387 cpu.cgroup_quota = MetricState::Stale {
388 value: CpuQuota::new(200_000, 100_000).expect("2.0"),
389 age: Duration::from_secs(45),
390 };
391 assert!(same(cpu.effective_cores(), 2.0));
392 assert!(cpu.is_cpu_limited());
393 }
394
395 #[test]
396 fn a_quota_cannot_be_built_from_a_period_it_cannot_divide_by() {
397 // Unrepresentable rather than checked at every use site.
398 assert!(CpuQuota::new(100_000, 0).is_none());
399 // A group allowed no CPU time at all is hostile but real, and hiding it would
400 // report an unrestricted machine.
401 let starved = CpuQuota::new(0, 100_000).expect("zero quota is a real limit");
402 assert!(same(starved.cores(), 0.0));
403 // …but it is not a *ceiling* below the host count in any useful sense, so the
404 // effective figure stays the machine's rather than becoming zero cores.
405 let mut cpu = CpuSnapshot::warming_up(4);
406 cpu.cgroup_quota = MetricState::Available(starved);
407 assert!(same(cpu.effective_cores(), 4.0));
408 }
409
410 #[test]
411 fn a_quota_keeps_the_figures_it_was_configured_with() {
412 // The ratio is what a view shows; the pair is what someone comparing this with
413 // `cpu.max` needs, and deriving it back from 1.5 would lose the period.
414 let quota = CpuQuota::new(150_000, 100_000).expect("1.5");
415 assert_eq!((quota.quota_us(), quota.period_us()), (150_000, 100_000));
416 }
417
418 #[test]
419 fn core_normalization_is_the_identity() {
420 let cpu = Percent::new(287.0).expect("valid");
421 let out = CpuNormalization::Core.apply(cpu, 8).expect("valid");
422 assert!((out.value() - 287.0).abs() < f32::EPSILON);
423 }
424
425 #[test]
426 fn machine_normalization_divides_by_the_logical_cpu_count() {
427 let cpu = Percent::new(400.0).expect("valid");
428 let out = CpuNormalization::Machine.apply(cpu, 8).expect("valid");
429 assert!((out.value() - 50.0).abs() < f32::EPSILON);
430 }
431
432 #[test]
433 fn machine_normalization_is_undefined_without_a_cpu_count() {
434 let cpu = Percent::new(400.0).expect("valid");
435 assert!(CpuNormalization::Machine.apply(cpu, 0).is_none());
436 }
437
438 #[test]
439 fn the_default_convention_is_documented_as_per_core() {
440 assert_eq!(CpuNormalization::default(), CpuNormalization::Core);
441 assert!(
442 CpuNormalization::default()
443 .description()
444 .contains("exceed 100%")
445 );
446 }
447
448 #[test]
449 fn load_per_cpu_is_undefined_without_a_cpu_count() {
450 let load = LoadSnapshot {
451 one: 11.4,
452 five: 8.0,
453 fifteen: 4.0,
454 };
455 assert!(load.per_cpu(0).is_none());
456 let per_cpu = load.per_cpu(8).expect("valid");
457 assert!((per_cpu - 1.425).abs() < 0.001);
458 }
459
460 #[test]
461 fn a_warming_up_cpu_snapshot_reports_no_utilization() {
462 let cpu = CpuSnapshot::warming_up(8);
463 assert_eq!(cpu.logical_count, 8);
464 assert!(cpu.total.fresh().is_none());
465 assert!(cpu.total.is_warming_up());
466 }
467}