Skip to main content

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}