use core_foundation::dictionary::CFDictionaryRef;
use serde::Serialize;
use std::{collections::HashMap, time::Duration};
use crate::shared::{ioreport_channels_filter, zero_div};
use crate::sources::{
IOHIDSensors, IOReport, SMC, SocInfo, cfio_get_residencies, cfio_watts, get_soc_info, libc_ram,
libc_swap,
};
type WithError<T> = Result<T, Box<dyn std::error::Error>>;
type CpuCoreKey = String;
type FreqMetrics = (u32, f32, f32);
const CPU_FREQ_CORE_SUBG: &str = "CPU Core Performance States";
const GPU_FREQ_DICE_SUBG: &str = "GPU Performance States";
#[derive(Debug, Default, Serialize)]
pub struct TempMetrics {
pub cpu_temp_avg: f32,
pub gpu_temp_avg: f32,
}
#[derive(Debug, Default, Serialize)]
pub struct MemMetrics {
pub ram_total: u64,
pub ram_usage: u64,
pub swap_total: u64,
pub swap_usage: u64,
}
#[derive(Debug, Default, Serialize)]
pub struct FanMetric {
pub name: String,
pub rpm: u32,
pub max_rpm: Option<u32>,
}
#[derive(Debug, Default, Serialize)]
pub struct CpuCoreMetrics {
pub die_id: usize,
pub core_id: usize,
pub freq_mhz: u32,
pub scaled_ratio: f32,
pub active_ratio: f32,
}
struct SmcSensors {
smc: SMC,
cpu_keys: Vec<String>,
gpu_keys: Vec<String>,
fan_keys: Vec<String>,
}
#[derive(Debug, Default, Serialize)]
pub struct Metrics {
pub temp: TempMetrics,
pub memory: MemMetrics,
pub fans: Vec<FanMetric>,
pub cpu_scaled_ratio: f32,
pub cpu_active_ratio: f32,
pub ecpu_freq_mhz: u32,
pub ecpu_scaled_ratio: f32,
pub ecpu_active_ratio: f32,
pub pcpu_freq_mhz: u32,
pub pcpu_scaled_ratio: f32,
pub pcpu_active_ratio: f32,
pub ecpu_cores: Vec<CpuCoreMetrics>,
pub pcpu_cores: Vec<CpuCoreMetrics>,
pub gpu_freq_mhz: u32,
pub gpu_scaled_ratio: f32,
pub gpu_active_ratio: f32,
pub cpu_power: f32,
pub gpu_power: f32,
pub ane_power: f32,
pub all_power: f32,
pub sys_power: f32,
pub ram_power: f32,
pub gpu_ram_power: f32,
}
fn is_valid_temp(val: f32) -> bool {
val > 0.0 && val <= 150.0
}
fn is_valid_fan_rpm(val: f32) -> bool {
(0.0..=100_000.0).contains(&val)
}
fn fan_rpm_value(val: f32) -> Option<u32> {
if is_valid_fan_rpm(val) { Some(val.trunc() as u32) } else { None }
}
fn aggregate_frequency(cores: &[CpuCoreMetrics], min_frequency_mhz: u32) -> u32 {
let average =
zero_div(cores.iter().map(|core| core.freq_mhz as f64).sum::<f64>(), cores.len() as f64);
average.max(min_frequency_mhz as f64) as u32
}
fn aggregate_ioreport_metrics(mut rs: Metrics, soc: &SocInfo) -> Metrics {
let ecpu_total_scaled: f32 = rs.ecpu_cores.iter().map(|core| core.scaled_ratio).sum();
let pcpu_total_scaled: f32 = rs.pcpu_cores.iter().map(|core| core.scaled_ratio).sum();
let ecpu_total_active: f32 = rs.ecpu_cores.iter().map(|core| core.active_ratio).sum();
let pcpu_total_active: f32 = rs.pcpu_cores.iter().map(|core| core.active_ratio).sum();
let ecores = rs.ecpu_cores.len().max(soc.ecpu_cores as usize) as f32;
let pcores = rs.pcpu_cores.len().max(soc.pcpu_cores as usize) as f32;
let tcores = ecores + pcores;
let ecpu_min_frequency_mhz = soc.ecpu_freqs.first().copied().unwrap_or_default();
let pcpu_min_frequency_mhz = soc.pcpu_freqs.first().copied().unwrap_or_default();
rs.ecpu_freq_mhz = aggregate_frequency(&rs.ecpu_cores, ecpu_min_frequency_mhz);
rs.ecpu_scaled_ratio = zero_div(ecpu_total_scaled, ecores);
rs.ecpu_active_ratio = zero_div(ecpu_total_active, ecores);
rs.pcpu_freq_mhz = aggregate_frequency(&rs.pcpu_cores, pcpu_min_frequency_mhz);
rs.pcpu_scaled_ratio = zero_div(pcpu_total_scaled, pcores);
rs.pcpu_active_ratio = zero_div(pcpu_total_active, pcores);
rs.cpu_scaled_ratio = zero_div(ecpu_total_scaled + pcpu_total_scaled, tcores);
rs.cpu_active_ratio = zero_div(ecpu_total_active + pcpu_total_active, tcores);
rs.all_power = rs.cpu_power + rs.gpu_power + rs.ane_power;
rs
}
fn smc_numeric_value(data: &[u8], unit: &str) -> Option<f32> {
match unit {
"flt " if data.len() == 4 => Some(f32::from_le_bytes(data.try_into().ok()?)),
"fpe2" if data.len() >= 2 => Some(((data[0] as u16) << 6 | ((data[1] as u16) >> 2)) as f32),
"ui8 " if !data.is_empty() => Some(data[0] as f32),
"ui16" if data.len() >= 2 => Some(u16::from_be_bytes(data[0..2].try_into().ok()?) as f32),
"ui32" if data.len() >= 4 => Some(u32::from_be_bytes(data[0..4].try_into().ok()?) as f32),
_ => None,
}
}
fn read_smc_numeric_u32(smc: &mut SMC, key: &str) -> Option<u32> {
let val = smc.read_val(key).ok()?;
let val = smc_numeric_value(&val.data, &val.unit)?;
fan_rpm_value(val)
}
fn calc_freq_from_residencies(items: &[(String, i64)], freqs: &[u32]) -> FreqMetrics {
let (len1, len2) = (items.len(), freqs.len());
assert!(len1 > len2, "calc_freq invalid data: {len1} vs {len2}");
let offset = items
.iter()
.position(|x| x.0 != "IDLE" && x.0 != "DOWN" && x.0 != "OFF")
.expect("calc_freq missing active states");
let usage = items.iter().skip(offset).take(freqs.len()).map(|x| x.1 as f64).sum::<f64>();
let total = items.iter().map(|x| x.1 as f64).sum::<f64>();
let mut avg_freq = 0f64;
for i in 0..freqs.len() {
let percent = zero_div(items[i + offset].1 as _, usage);
avg_freq += percent * freqs[i] as f64;
}
let active_ratio = zero_div(usage, total);
let min_freq = *freqs.first().unwrap() as f64;
let max_freq = *freqs.last().unwrap() as f64;
let scaled_ratio = (avg_freq.max(min_freq) * active_ratio) / max_freq;
(avg_freq as u32, scaled_ratio as f32, active_ratio as f32)
}
fn calc_freq(item: CFDictionaryRef, freqs: &[u32]) -> FreqMetrics {
calc_freq_from_residencies(&cfio_get_residencies(item), freqs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CpuCoreKind {
E,
P,
}
fn parse_cpu_core_id(channel: &str, prefix: &str) -> Option<usize> {
let start = channel.find(prefix)? + prefix.len();
let digits = channel[start..].chars().take_while(|c| c.is_ascii_digit()).collect::<String>();
if digits.is_empty() { None } else { digits.parse().ok() }
}
fn parse_die_id(channel: &str) -> usize {
let Some(rest) = channel.strip_prefix("DIE_") else { return 0 };
rest.split_once('_').and_then(|(id, _)| id.parse().ok()).unwrap_or(0)
}
fn cpu_core_prefix(channel: &str) -> Option<&'static str> {
["PCPU", "ECPU", "MCPU"].into_iter().find(|prefix| channel.contains(prefix))
}
fn cpu_core_sort_key(channel: &str) -> (usize, usize, usize) {
let die_id = parse_die_id(channel);
let Some(prefix) = cpu_core_prefix(channel) else { return (die_id, 0, 0) };
let start = channel.find(prefix).unwrap_or_default() + prefix.len();
let suffix = &channel[start..];
if let Some((cluster_id, core_id)) = suffix.split_once("_CPU") {
let cluster_id = if cluster_id.is_empty() { 0 } else { cluster_id.parse().unwrap_or(0) };
return (die_id, cluster_id, core_id.parse().unwrap_or(0));
}
(die_id, 0, parse_cpu_core_id(channel, prefix).unwrap_or(0))
}
fn parse_cpu_core_channel(channel: &str) -> Option<(CpuCoreKind, CpuCoreKey)> {
let kind = if channel.contains("PCPU") {
CpuCoreKind::P
} else if channel.contains("ECPU") || channel.contains("MCPU") {
CpuCoreKind::E
} else {
return None;
};
Some((kind, channel.to_owned()))
}
fn collect_cpu_core_metrics(metrics: HashMap<CpuCoreKey, FreqMetrics>) -> Vec<CpuCoreMetrics> {
let mut metrics: Vec<_> = metrics.into_iter().collect();
metrics.sort_by_key(|(channel, _)| cpu_core_sort_key(channel));
let mut next_clustered_core_id = HashMap::<usize, usize>::new();
metrics
.into_iter()
.map(|(channel, (freq_mhz, scaled_ratio, active_ratio))| {
let die_id = parse_die_id(&channel);
let core_id = if channel.contains("_CPU") {
let next = next_clustered_core_id.entry(die_id).or_default();
let core_id = *next;
*next += 1;
core_id
} else {
cpu_core_prefix(&channel)
.and_then(|prefix| parse_cpu_core_id(&channel, prefix))
.unwrap_or_default()
};
CpuCoreMetrics { die_id, core_id, freq_mhz, scaled_ratio, active_ratio }
})
.collect()
}
fn init_smc() -> WithError<SmcSensors> {
let mut smc = SMC::new()?;
let mut cpu_sensors = Vec::new();
let mut gpu_sensors = Vec::new();
let mut fan_sensors = Vec::new();
let names = smc.read_all_keys().unwrap_or(vec![]);
for name in &names {
if name.len() == 4 && name.starts_with('F') && name.ends_with("Ac") {
fan_sensors.push(name.clone());
continue;
}
let is_cpu = name.starts_with("Tp") || name.starts_with("Te") || name.starts_with("Ts");
let is_gpu = name.starts_with("Tg");
if !is_cpu && !is_gpu {
continue;
}
if smc.read_float_val(name).is_err() {
continue;
}
if is_cpu {
cpu_sensors.push(name.clone());
} else if is_gpu {
gpu_sensors.push(name.clone());
}
}
fan_sensors.sort();
fan_sensors.dedup();
Ok(SmcSensors { smc, cpu_keys: cpu_sensors, gpu_keys: gpu_sensors, fan_keys: fan_sensors })
}
pub struct Sampler {
soc: SocInfo,
ior: IOReport,
hid: IOHIDSensors,
smc: SMC,
smc_cpu_keys: Vec<String>,
smc_gpu_keys: Vec<String>,
smc_fan_keys: Vec<String>,
}
impl Sampler {
pub fn new() -> WithError<Self> {
let soc = get_soc_info()?;
let ior = IOReport::with_filter(Some(ioreport_channels_filter))?;
let hid = IOHIDSensors::new()?;
let smc_sensors = init_smc()?;
Ok(Sampler {
soc,
ior,
hid,
smc: smc_sensors.smc,
smc_cpu_keys: smc_sensors.cpu_keys,
smc_gpu_keys: smc_sensors.gpu_keys,
smc_fan_keys: smc_sensors.fan_keys,
})
}
fn get_temp_smc(&mut self) -> WithError<TempMetrics> {
let mut cpu_metrics = Vec::new();
for sensor in &self.smc_cpu_keys {
let val = self.smc.read_float_val(sensor)?;
if is_valid_temp(val) {
cpu_metrics.push(val);
}
}
let mut gpu_metrics = Vec::new();
for sensor in &self.smc_gpu_keys {
let val = self.smc.read_float_val(sensor)?;
if is_valid_temp(val) {
gpu_metrics.push(val);
}
}
let cpu_temp_avg = zero_div(cpu_metrics.iter().sum::<f32>(), cpu_metrics.len() as f32);
let gpu_temp_avg = zero_div(gpu_metrics.iter().sum::<f32>(), gpu_metrics.len() as f32);
Ok(TempMetrics { cpu_temp_avg, gpu_temp_avg })
}
fn get_temp_hid(&mut self) -> WithError<TempMetrics> {
let metrics = self.hid.get_metrics();
let mut cpu_values = Vec::new();
let mut gpu_values = Vec::new();
for (name, value) in &metrics {
if name.starts_with("pACC MTR Temp Sensor") || name.starts_with("eACC MTR Temp Sensor") {
if is_valid_temp(*value) {
cpu_values.push(*value);
}
continue;
}
if name.starts_with("GPU MTR Temp Sensor") {
if is_valid_temp(*value) {
gpu_values.push(*value);
}
continue;
}
}
let cpu_temp_avg = zero_div(cpu_values.iter().sum(), cpu_values.len() as f32);
let gpu_temp_avg = zero_div(gpu_values.iter().sum(), gpu_values.len() as f32);
Ok(TempMetrics { cpu_temp_avg, gpu_temp_avg })
}
fn get_temp(&mut self) -> WithError<TempMetrics> {
match !self.smc_cpu_keys.is_empty() {
true => self.get_temp_smc(),
false => self.get_temp_hid(),
}
}
fn get_fans(&mut self) -> Vec<FanMetric> {
let mut fans = Vec::new();
for (i, key) in self.smc_fan_keys.iter().enumerate() {
let Some(rpm) = read_smc_numeric_u32(&mut self.smc, key) else { continue };
let name = format!("fan{i}");
let max_rpm = match key.strip_suffix("Ac") {
Some(prefix) => {
read_smc_numeric_u32(&mut self.smc, &format!("{prefix}Mx")).filter(|rpm| *rpm > 0)
}
None => None,
};
fans.push(FanMetric { name, rpm, max_rpm });
}
fans
}
fn get_mem(&mut self) -> WithError<MemMetrics> {
let (ram_usage, ram_total) = libc_ram()?;
let (swap_usage, swap_total) = libc_swap()?;
Ok(MemMetrics { ram_total, ram_usage, swap_total, swap_usage })
}
fn get_sys_power(&mut self) -> WithError<f32> {
self.smc.read_float_val("PSTR")
}
fn get_ioreport_metrics(
&self,
sample: crate::sources::IOReportIterator,
dt: Duration,
) -> WithError<Metrics> {
let mut ecpu_map: HashMap<CpuCoreKey, FreqMetrics> = HashMap::new();
let mut pcpu_map: HashMap<CpuCoreKey, FreqMetrics> = HashMap::new();
let mut rs = Metrics::default();
for x in sample {
if x.group == "CPU Stats" && x.subgroup == CPU_FREQ_CORE_SUBG {
match parse_cpu_core_channel(&x.channel) {
Some((CpuCoreKind::P, key)) => {
let metrics = calc_freq(x.item, &self.soc.pcpu_freqs);
pcpu_map.insert(key, metrics);
continue;
}
Some((CpuCoreKind::E, key)) => {
ecpu_map.insert(key, calc_freq(x.item, &self.soc.ecpu_freqs));
continue;
}
None => {}
}
}
if x.group == "GPU Stats" && x.subgroup == GPU_FREQ_DICE_SUBG {
match x.channel.as_str() {
"GPUPH" => {
let (freq, scaled_ratio, active_ratio) = calc_freq(x.item, &self.soc.gpu_freqs[1..]);
rs.gpu_freq_mhz = freq;
rs.gpu_scaled_ratio = scaled_ratio;
rs.gpu_active_ratio = active_ratio;
}
_ => {}
}
}
if x.group == "Energy Model" {
match x.channel.as_str() {
"GPU Energy" => rs.gpu_power += cfio_watts(x.item, &x.unit, dt)?,
c if c.ends_with("CPU Energy") => rs.cpu_power += cfio_watts(x.item, &x.unit, dt)?,
c if c.starts_with("ANE") => rs.ane_power += cfio_watts(x.item, &x.unit, dt)?,
c if c.starts_with("DRAM") => rs.ram_power += cfio_watts(x.item, &x.unit, dt)?,
c if c.starts_with("GPU SRAM") => rs.gpu_ram_power += cfio_watts(x.item, &x.unit, dt)?,
_ => {}
}
}
}
rs.ecpu_cores = collect_cpu_core_metrics(ecpu_map);
rs.pcpu_cores = collect_cpu_core_metrics(pcpu_map);
Ok(rs)
}
pub fn get_metrics(&mut self, duration: u32) -> WithError<Metrics> {
let duration = Duration::from_millis(duration as u64);
let (sample, elapsed) = self.ior.get_sample_interval(duration);
let mut rs = aggregate_ioreport_metrics(self.get_ioreport_metrics(sample, elapsed)?, &self.soc);
rs.memory = self.get_mem()?;
rs.temp = self.get_temp()?;
rs.fans = self.get_fans();
rs.sys_power = match self.get_sys_power() {
Ok(val) => val.max(rs.all_power),
Err(_) => 0.0,
};
Ok(rs)
}
pub fn get_soc_info(&self) -> &SocInfo {
&self.soc
}
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use crate::sources::SocInfo;
use super::{
CpuCoreKind, CpuCoreMetrics, Metrics, aggregate_ioreport_metrics, calc_freq_from_residencies,
collect_cpu_core_metrics, parse_cpu_core_channel, smc_numeric_value,
};
fn core(
die_id: usize,
core_id: usize,
freq_mhz: u32,
scaled_ratio: f32,
active_ratio: f32,
) -> CpuCoreMetrics {
CpuCoreMetrics { die_id, core_id, freq_mhz, scaled_ratio, active_ratio }
}
fn soc_info(ecpu_cores: u8, pcpu_cores: u8) -> SocInfo {
SocInfo {
ecpu_cores,
pcpu_cores,
ecpu_freqs: vec![800],
pcpu_freqs: vec![1800],
..Default::default()
}
}
#[test]
fn parse_smc_numeric_values() {
assert_eq!(smc_numeric_value(&42.5f32.to_le_bytes(), "flt "), Some(42.5));
assert_eq!(smc_numeric_value(&[0x13, 0x88], "fpe2"), Some(1250.0));
assert_eq!(smc_numeric_value(&[0x04, 0xd2], "ui16"), Some(1234.0));
assert_eq!(smc_numeric_value(&[0x00, 0x00, 0x04, 0xd2], "ui32"), Some(1234.0));
}
#[test]
fn aggregates_ioreport_metrics() {
let rs = aggregate_ioreport_metrics(
Metrics {
ecpu_cores: vec![core(0, 0, 2000, 1.0, 1.0)],
pcpu_cores: vec![core(0, 0, 0, 0.0, 0.0), core(0, 1, 4000, 1.0, 1.0)],
cpu_power: 1.5,
gpu_power: 2.0,
ane_power: 0.5,
..Default::default()
},
&soc_info(2, 1),
);
assert_eq!(rs.ecpu_freq_mhz, 2000);
assert_eq!(rs.ecpu_scaled_ratio, 0.5);
assert_eq!(rs.ecpu_active_ratio, 0.5);
assert_eq!(rs.pcpu_freq_mhz, 2000);
assert_eq!(rs.pcpu_scaled_ratio, 0.5);
assert_eq!(rs.pcpu_active_ratio, 0.5);
assert_eq!(rs.cpu_scaled_ratio, 0.5);
assert_eq!(rs.cpu_active_ratio, 0.5);
assert_eq!(rs.all_power, 4.0);
}
#[test]
fn frequency_uses_minimum_floor_when_cluster_is_idle() {
let rs = aggregate_ioreport_metrics(
Metrics {
ecpu_cores: vec![core(0, 0, 0, 0.0, 0.0), core(0, 1, 0, 0.0, 0.0)],
pcpu_cores: vec![core(0, 0, 0, 0.0, 0.0)],
..Default::default()
},
&soc_info(0, 0),
);
assert_eq!(rs.ecpu_freq_mhz, 800);
assert_eq!(rs.pcpu_freq_mhz, 1800);
}
#[test]
fn orders_named_core_metrics() {
let cores = collect_cpu_core_metrics(HashMap::from([
("DIE_1_ECPU0".into(), (2000, 0.50, 0.75)),
("DIE_0_ECPU1".into(), (1000, 0.25, 0.50)),
]));
assert_eq!((cores[0].die_id, cores[0].core_id), (0, 1));
assert_eq!((cores[1].die_id, cores[1].core_id), (1, 0));
}
#[test]
fn calculates_frequency_over_the_complete_residency_window() {
let (frequency, scaled_ratio, active_ratio) = calc_freq_from_residencies(
&[
("DOWN".into(), 0),
("IDLE".into(), 500),
("1000 MHz".into(), 100),
("2000 MHz".into(), 400),
],
&[1000, 2000],
);
assert_eq!(frequency, 1800);
assert!((scaled_ratio - 0.45).abs() < f32::EPSILON);
assert!((active_ratio - 0.5).abs() < f32::EPSILON);
}
#[test]
fn treats_down_as_dynamic_inactive_residency() {
let (frequency, scaled_ratio, active_ratio) = calc_freq_from_residencies(
&[
("DOWN".into(), 800),
("IDLE".into(), 100),
("1000 MHz".into(), 100),
("2000 MHz".into(), 0),
],
&[1000, 2000],
);
assert_eq!(frequency, 1000);
assert!((scaled_ratio - 0.05).abs() < f32::EPSILON);
assert!((active_ratio - 0.1).abs() < f32::EPSILON);
}
#[test]
fn ultra_cpu_channel_matching() {
let cases = [
("DIE_0_ECPU0", Some(CpuCoreKind::E)),
("DIE_1_ECPU0", Some(CpuCoreKind::E)),
("DIE_0_PCPU0", Some(CpuCoreKind::P)),
("DIE_1_PCPU0", Some(CpuCoreKind::P)),
("ECPU7", Some(CpuCoreKind::E)),
("PCPU12", Some(CpuCoreKind::P)),
("MCPU3", Some(CpuCoreKind::E)), ("GPU0", None),
];
for (channel, expected) in cases {
assert_eq!(
parse_cpu_core_channel(channel).map(|(kind, _)| kind),
expected,
"channel {channel}"
);
}
}
#[test]
fn parses_real_m3_ultra_core_channels_without_collisions() {
let channels = [
"DIE_0_ECPU_CPU0",
"DIE_0_ECPU_CPU1",
"DIE_0_PCPU_CPU0",
"DIE_0_PCPU_CPU1",
"DIE_0_PCPU1_CPU0",
"DIE_0_PCPU1_CPU1",
"DIE_1_ECPU_CPU0",
"DIE_1_PCPU_CPU0",
"DIE_1_PCPU1_CPU0",
];
let parsed = channels.map(parse_cpu_core_channel);
assert!(parsed.iter().all(Option::is_some));
let unique = parsed
.into_iter()
.flatten()
.map(|(kind, key)| (kind == CpuCoreKind::P, key))
.collect::<HashSet<_>>();
assert_eq!(unique.len(), channels.len());
}
#[test]
fn flattens_real_ultra_cluster_channels_into_core_ids() {
let cores = collect_cpu_core_metrics(HashMap::from([
("DIE_0_PCPU1_CPU0".into(), (2000, 0.50, 0.75)),
("DIE_0_PCPU_CPU1".into(), (1100, 0.25, 0.50)),
("DIE_0_PCPU_CPU0".into(), (1000, 0.20, 0.40)),
("DIE_1_PCPU_CPU0".into(), (1200, 0.30, 0.60)),
]));
assert_eq!(
cores.iter().map(|core| (core.die_id, core.core_id, core.freq_mhz)).collect::<Vec<_>>(),
[(0, 0, 1000), (0, 1, 1100), (0, 2, 2000), (1, 0, 1200)]
);
}
}