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/// The per-state split of CPU time.
32///
33/// macOS exposes only `user`, `system`, `nice`, and `idle`; the Linux-only
34/// fields are [`MetricState::Unsupported`] there rather than zero (§4).
35#[derive(Clone, Copy, Debug, PartialEq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize))]
37pub struct CpuBreakdown {
38    /// Time in user mode.
39    pub user: Percent,
40    /// Time in kernel mode.
41    pub system: Percent,
42    /// Time in low-priority user mode.
43    pub nice: Percent,
44    /// Idle time.
45    pub idle: Percent,
46    /// Time waiting on I/O. Linux only.
47    pub iowait: MetricState<Percent>,
48    /// Time servicing hardware interrupts. Linux only.
49    pub irq: MetricState<Percent>,
50    /// Time servicing soft interrupts. Linux only.
51    pub softirq: MetricState<Percent>,
52    /// Time stolen by the hypervisor. Linux only, and the most useful signal
53    /// that a VM is oversubscribed.
54    pub steal: MetricState<Percent>,
55}
56
57/// How process CPU percentages are scaled (§8.3).
58#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
61pub enum CpuNormalization {
62    /// One core = 100%. A process using four cores fully reads 400%.
63    ///
64    /// The default, matching `top` and `htop`.
65    #[default]
66    Core,
67    /// The whole machine = 100%. A process using four of eight cores reads 50%.
68    Machine,
69}
70
71impl CpuNormalization {
72    /// The documentation string shown in help and `docs/metrics.md` (§8.3).
73    #[must_use]
74    pub const fn description(self) -> &'static str {
75        match self {
76            Self::Core => "one core = 100%, so a multi-threaded process may exceed 100%",
77            Self::Machine => "the whole machine = 100%, so no process exceeds 100%",
78        }
79    }
80
81    /// Converts a core-normalized percentage into this convention.
82    ///
83    /// Returns `None` when `logical_cpus` is zero, because there is no defined
84    /// machine share to scale against.
85    #[must_use]
86    pub fn apply(self, core_normalized: Percent, logical_cpus: u16) -> Option<Percent> {
87        match self {
88            Self::Core => Some(core_normalized),
89            Self::Machine => {
90                if logical_cpus == 0 {
91                    return None;
92                }
93                Percent::new(core_normalized.value() / f32::from(logical_cpus))
94            }
95        }
96    }
97}
98
99/// System-wide CPU state.
100#[derive(Clone, Debug, PartialEq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize))]
102pub struct CpuSnapshot {
103    /// Logical CPU count, including SMT siblings. Always known.
104    pub logical_count: u16,
105    /// Physical core count, where the platform reports it.
106    pub physical_count: MetricState<u16>,
107    /// Aggregate machine utilization, `0..=100` (§8.3).
108    pub total: MetricState<CpuUsage>,
109    /// Per-logical-CPU utilization, in stable index order.
110    pub per_core: MetricState<Vec<CpuUsage>>,
111    /// Current clock, where reported.
112    pub frequency_mhz: MetricState<u64>,
113}
114
115impl CpuSnapshot {
116    /// A snapshot with no measurements yet, for the first frame.
117    #[must_use]
118    pub const fn warming_up(logical_count: u16) -> Self {
119        Self {
120            logical_count,
121            physical_count: MetricState::WarmingUp,
122            total: MetricState::WarmingUp,
123            per_core: MetricState::WarmingUp,
124            frequency_mhz: MetricState::WarmingUp,
125        }
126    }
127}
128
129/// Load averages, which are run-queue lengths rather than percentages.
130#[derive(Clone, Copy, Debug, PartialEq)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize))]
132pub struct LoadSnapshot {
133    /// One-minute average.
134    pub one: f32,
135    /// Five-minute average.
136    pub five: f32,
137    /// Fifteen-minute average.
138    pub fifteen: f32,
139}
140
141impl LoadSnapshot {
142    /// The one-minute load expressed per logical CPU.
143    ///
144    /// This is the only form in which load can be compared across machines, and
145    /// it is what the `load high relative to logical CPU count` rule uses
146    /// (§11.2). Returns `None` when the CPU count is unknown.
147    #[must_use]
148    pub fn per_cpu(&self, logical_cpus: u16) -> Option<f32> {
149        if logical_cpus == 0 {
150            return None;
151        }
152        let value = self.one / f32::from(logical_cpus);
153        value.is_finite().then_some(value)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn core_normalization_is_the_identity() {
163        let cpu = Percent::new(287.0).expect("valid");
164        let out = CpuNormalization::Core.apply(cpu, 8).expect("valid");
165        assert!((out.value() - 287.0).abs() < f32::EPSILON);
166    }
167
168    #[test]
169    fn machine_normalization_divides_by_the_logical_cpu_count() {
170        let cpu = Percent::new(400.0).expect("valid");
171        let out = CpuNormalization::Machine.apply(cpu, 8).expect("valid");
172        assert!((out.value() - 50.0).abs() < f32::EPSILON);
173    }
174
175    #[test]
176    fn machine_normalization_is_undefined_without_a_cpu_count() {
177        let cpu = Percent::new(400.0).expect("valid");
178        assert!(CpuNormalization::Machine.apply(cpu, 0).is_none());
179    }
180
181    #[test]
182    fn the_default_convention_is_documented_as_per_core() {
183        assert_eq!(CpuNormalization::default(), CpuNormalization::Core);
184        assert!(
185            CpuNormalization::default()
186                .description()
187                .contains("exceed 100%")
188        );
189    }
190
191    #[test]
192    fn load_per_cpu_is_undefined_without_a_cpu_count() {
193        let load = LoadSnapshot {
194            one: 11.4,
195            five: 8.0,
196            fifteen: 4.0,
197        };
198        assert!(load.per_cpu(0).is_none());
199        let per_cpu = load.per_cpu(8).expect("valid");
200        assert!((per_cpu - 1.425).abs() < 0.001);
201    }
202
203    #[test]
204    fn a_warming_up_cpu_snapshot_reports_no_utilization() {
205        let cpu = CpuSnapshot::warming_up(8);
206        assert_eq!(cpu.logical_count, 8);
207        assert!(cpu.total.fresh().is_none());
208        assert!(cpu.total.is_warming_up());
209    }
210}