use crate::model::MetricState;
use crate::units::Percent;
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CpuUsage {
pub busy: Percent,
pub breakdown: MetricState<CpuBreakdown>,
}
impl CpuUsage {
#[must_use]
pub const fn plain(busy: Percent) -> Self {
Self {
busy,
breakdown: MetricState::Unsupported,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CpuQuota {
quota_us: u64,
period_us: u64,
}
impl CpuQuota {
#[must_use]
pub fn new(quota_us: u64, period_us: u64) -> Option<Self> {
if period_us == 0 {
return None;
}
let quota = Self {
quota_us,
period_us,
};
quota.cores().is_finite().then_some(quota)
}
#[must_use]
pub fn cores(&self) -> f32 {
#[allow(clippy::cast_precision_loss)]
let cores = self.quota_us as f64 / self.period_us as f64;
#[allow(clippy::cast_possible_truncation)]
let cores = cores as f32;
cores
}
#[must_use]
pub const fn quota_us(&self) -> u64 {
self.quota_us
}
#[must_use]
pub const fn period_us(&self) -> u64 {
self.period_us
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CoreClass {
pub name: Box<str>,
pub logical: Vec<u16>,
pub physical_count: Option<u16>,
}
impl CoreClass {
#[must_use]
pub fn len(&self) -> usize {
self.logical.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.logical.is_empty()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CpuBreakdown {
pub user: Percent,
pub system: Percent,
pub nice: Percent,
pub idle: Percent,
pub iowait: MetricState<Percent>,
pub irq: MetricState<Percent>,
pub softirq: MetricState<Percent>,
pub steal: MetricState<Percent>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum CpuNormalization {
#[default]
Core,
Machine,
}
impl CpuNormalization {
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::Core => "one core = 100%, so a multi-threaded process may exceed 100%",
Self::Machine => "the whole machine = 100%, so no process exceeds 100%",
}
}
#[must_use]
pub fn apply(self, core_normalized: Percent, logical_cpus: u16) -> Option<Percent> {
match self {
Self::Core => Some(core_normalized),
Self::Machine => {
if logical_cpus == 0 {
return None;
}
Percent::new(core_normalized.value() / f32::from(logical_cpus))
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CpuSnapshot {
pub logical_count: u16,
pub physical_count: MetricState<u16>,
pub total: MetricState<CpuUsage>,
pub per_core: MetricState<Vec<CpuUsage>>,
pub frequency_mhz: MetricState<u64>,
pub cgroup_quota: MetricState<CpuQuota>,
pub core_classes: Vec<CoreClass>,
}
impl CpuSnapshot {
#[must_use]
pub fn effective_cores(&self) -> f32 {
let host = f32::from(self.logical_count);
match self.cgroup_quota.displayable() {
Some((quota, _)) if quota.cores() > 0.0 && quota.cores() < host => quota.cores(),
_ => host,
}
}
#[must_use]
pub fn is_cpu_limited(&self) -> bool {
self.cgroup_quota.displayable().is_some_and(|(quota, _)| {
quota.cores() > 0.0 && quota.cores() < f32::from(self.logical_count)
})
}
#[must_use]
pub const fn warming_up(logical_count: u16) -> Self {
Self {
logical_count,
physical_count: MetricState::WarmingUp,
total: MetricState::WarmingUp,
per_core: MetricState::WarmingUp,
frequency_mhz: MetricState::WarmingUp,
cgroup_quota: MetricState::WarmingUp,
core_classes: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct LoadSnapshot {
pub one: f32,
pub five: f32,
pub fifteen: f32,
}
impl LoadSnapshot {
#[must_use]
pub fn per_cpu(&self, logical_cpus: u16) -> Option<f32> {
if logical_cpus == 0 {
return None;
}
let value = self.one / f32::from(logical_cpus);
value.is_finite().then_some(value)
}
}
#[cfg(test)]
mod tests {
use core::time::Duration;
use super::*;
use crate::model::UnavailableReason;
fn same(left: f32, right: f32) -> bool {
(left - right).abs() < f32::EPSILON
}
#[test]
fn a_cgroup_quota_below_the_host_count_becomes_the_effective_ceiling() {
let mut cpu = CpuSnapshot::warming_up(64);
assert!(same(cpu.effective_cores(), 64.0));
assert!(!cpu.is_cpu_limited());
cpu.cgroup_quota = MetricState::Available(CpuQuota::new(150_000, 100_000).expect("1.5"));
assert!(same(cpu.effective_cores(), 1.5));
assert!(cpu.is_cpu_limited());
assert_eq!(cpu.logical_count, 64, "the host count is untouched");
}
#[test]
fn a_quota_above_the_host_count_is_not_a_ceiling() {
let mut cpu = CpuSnapshot::warming_up(64);
cpu.cgroup_quota = MetricState::Available(CpuQuota::new(12_800_000, 100_000).expect("128"));
assert!(same(cpu.effective_cores(), 64.0));
assert!(!cpu.is_cpu_limited());
}
#[test]
fn an_unavailable_quota_leaves_the_host_count_as_the_ceiling() {
let mut cpu = CpuSnapshot::warming_up(8);
for state in [
MetricState::Unsupported,
MetricState::WarmingUp,
MetricState::PermissionDenied,
MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed),
] {
cpu.cgroup_quota = state;
assert!(same(cpu.effective_cores(), 8.0), "{state:?}");
assert!(!cpu.is_cpu_limited(), "{state:?}");
}
}
#[test]
fn a_stale_quota_still_bounds_the_machine() {
let mut cpu = CpuSnapshot::warming_up(16);
cpu.cgroup_quota = MetricState::Stale {
value: CpuQuota::new(200_000, 100_000).expect("2.0"),
age: Duration::from_secs(45),
};
assert!(same(cpu.effective_cores(), 2.0));
assert!(cpu.is_cpu_limited());
}
#[test]
fn a_quota_cannot_be_built_from_a_period_it_cannot_divide_by() {
assert!(CpuQuota::new(100_000, 0).is_none());
let starved = CpuQuota::new(0, 100_000).expect("zero quota is a real limit");
assert!(same(starved.cores(), 0.0));
let mut cpu = CpuSnapshot::warming_up(4);
cpu.cgroup_quota = MetricState::Available(starved);
assert!(same(cpu.effective_cores(), 4.0));
}
#[test]
fn a_quota_keeps_the_figures_it_was_configured_with() {
let quota = CpuQuota::new(150_000, 100_000).expect("1.5");
assert_eq!((quota.quota_us(), quota.period_us()), (150_000, 100_000));
}
#[test]
fn core_normalization_is_the_identity() {
let cpu = Percent::new(287.0).expect("valid");
let out = CpuNormalization::Core.apply(cpu, 8).expect("valid");
assert!((out.value() - 287.0).abs() < f32::EPSILON);
}
#[test]
fn machine_normalization_divides_by_the_logical_cpu_count() {
let cpu = Percent::new(400.0).expect("valid");
let out = CpuNormalization::Machine.apply(cpu, 8).expect("valid");
assert!((out.value() - 50.0).abs() < f32::EPSILON);
}
#[test]
fn machine_normalization_is_undefined_without_a_cpu_count() {
let cpu = Percent::new(400.0).expect("valid");
assert!(CpuNormalization::Machine.apply(cpu, 0).is_none());
}
#[test]
fn the_default_convention_is_documented_as_per_core() {
assert_eq!(CpuNormalization::default(), CpuNormalization::Core);
assert!(
CpuNormalization::default()
.description()
.contains("exceed 100%")
);
}
#[test]
fn load_per_cpu_is_undefined_without_a_cpu_count() {
let load = LoadSnapshot {
one: 11.4,
five: 8.0,
fifteen: 4.0,
};
assert!(load.per_cpu(0).is_none());
let per_cpu = load.per_cpu(8).expect("valid");
assert!((per_cpu - 1.425).abs() < 0.001);
}
#[test]
fn a_warming_up_cpu_snapshot_reports_no_utilization() {
let cpu = CpuSnapshot::warming_up(8);
assert_eq!(cpu.logical_count, 8);
assert!(cpu.total.fresh().is_none());
assert!(cpu.total.is_warming_up());
}
}