use crate::model::DeviceId;
pub const PROCESS_HINT: &str = "per-process GPU data is not available on macOS: the OS \
does not expose it to third-party apps (powermetrics requires root; Activity Monitor \
uses private plumbing) — device-level metrics only";
pub const SOURCE_CAVEAT: &str = "read via undocumented macOS interfaces (IOKit \
PerformanceStatistics / IOReport); may break on macOS updates";
pub const MEM_TOTAL_CAVEAT: &str = "memory total is a unified-memory working-set budget \
(Metal recommendedMaxWorkingSetSize) — Apple publishes no total-VRAM figure and \
unified memory has none";
pub fn tier_a_source_caveat(has_unified_memory: Option<bool>) -> &'static str {
match has_unified_memory {
Some(false) => {
"memory total is a Metal working-set budget (recommendedMaxWorkingSetSize), \
not a measured VRAM capacity"
}
_ => MEM_TOTAL_CAVEAT,
}
}
pub fn device_id_for(metal_name: &str) -> DeviceId {
let mut slug = String::new();
for c in metal_name.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
}
let slug = slug.trim_end_matches('-');
let slug = slug.strip_prefix("apple-").unwrap_or(slug);
let slug = if slug.is_empty() { "unknown" } else { slug };
DeviceId(format!("apple:{slug}"))
}
pub mod parse {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChannelDesc {
pub group: String,
pub subgroup: String,
pub channel: String,
pub unit: String,
}
impl ChannelDesc {
pub fn to_line(&self) -> String {
format!(
"channel|{}|{}|{}|{}",
self.group, self.subgroup, self.channel, self.unit
)
}
pub fn from_line(line: &str) -> Option<Self> {
let rest = line.trim().strip_prefix("channel|")?;
let mut parts = rest.splitn(4, '|');
let group = parts.next()?.to_string();
let subgroup = parts.next()?.to_string();
let channel = parts.next()?.to_string();
let unit = parts.next()?.to_string();
Some(Self {
group,
subgroup,
channel,
unit,
})
}
}
pub fn parse_channels(text: &str) -> Vec<ChannelDesc> {
text.lines().filter_map(ChannelDesc::from_line).collect()
}
pub fn parse_states(text: &str) -> Vec<(String, u64)> {
text.lines()
.filter_map(|l| {
let rest = l.trim().strip_prefix("state|")?;
let (name, ticks) = rest.split_once('|')?;
Some((name.to_string(), ticks.trim().parse().ok()?))
})
.collect()
}
pub fn parse_hex(text: &str) -> Option<Vec<u8>> {
let mut digits = String::new();
for line in text.lines() {
let data = line.split('#').next().unwrap_or("");
digits.extend(data.chars().filter(|c| !c.is_whitespace()));
}
if !digits.len().is_multiple_of(2) || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
(0..digits.len())
.step_by(2)
.map(|i| u8::from_str_radix(&digits[i..i + 2], 16).ok())
.collect()
}
pub fn is_gpu_energy_channel(c: &ChannelDesc) -> bool {
c.group == "Energy Model" && c.channel.contains("GPU Energy")
}
pub fn is_gpu_perf_states_channel(c: &ChannelDesc) -> bool {
c.group == "GPU Stats"
&& c.subgroup == "GPU Performance States"
&& c.channel.contains("GPUPH")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EnergyUnit {
Millijoules,
Microjoules,
Nanojoules,
}
pub fn energy_unit(label: &str) -> Option<EnergyUnit> {
match label.trim() {
"mJ" => Some(EnergyUnit::Millijoules),
"uJ" | "µJ" => Some(EnergyUnit::Microjoules),
"nJ" => Some(EnergyUnit::Nanojoules),
_ => None,
}
}
pub fn power_mw_from_energy(delta_energy: u64, unit: EnergyUnit, dt_ms: u64) -> Option<u32> {
if dt_ms == 0 {
return None; }
let to_mj = match unit {
EnergyUnit::Millijoules => 1.0,
EnergyUnit::Microjoules => 1e-3,
EnergyUnit::Nanojoules => 1e-6,
};
let mw = delta_energy as f64 * to_mj * 1000.0 / dt_ms as f64;
if !mw.is_finite() || mw < 0.0 {
return None;
}
Some(mw.round().min(f64::from(u32::MAX)) as u32)
}
pub fn is_idle_state(name: &str) -> bool {
let n = name.trim();
n.eq_ignore_ascii_case("off") || n.to_ascii_uppercase().starts_with("IDLE")
}
pub fn util_pct_from_residency(states: &[(String, u64)]) -> Option<f32> {
let total: u128 = states.iter().map(|(_, t)| u128::from(*t)).sum();
if total == 0 {
return None;
}
let active: u128 = states
.iter()
.filter(|(n, _)| !is_idle_state(n))
.map(|(_, t)| u128::from(*t))
.sum();
Some((active as f64 / total as f64 * 100.0) as f32)
}
pub fn weighted_freq_mhz(states: &[(String, u64)], dvfs_mhz: &[u32]) -> Option<u32> {
let active: Vec<u64> = states
.iter()
.filter(|(n, _)| !is_idle_state(n))
.map(|(_, t)| *t)
.collect();
if active.is_empty() || active.len() != dvfs_mhz.len() {
return None;
}
let ticks: u128 = active.iter().map(|t| u128::from(*t)).sum();
if ticks == 0 {
return Some(0);
}
let weighted: u128 = active
.iter()
.zip(dvfs_mhz)
.map(|(t, f)| u128::from(*t) * u128::from(*f))
.sum();
u32::try_from((weighted + ticks / 2) / ticks).ok()
}
pub fn parse_voltage_states(raw: &[u8]) -> Vec<u32> {
raw.chunks_exact(8)
.filter_map(|row| {
let freq_raw = u32::from_le_bytes([row[0], row[1], row[2], row[3]]);
if freq_raw == 0 {
return None;
}
Some(normalize_mhz(freq_raw))
})
.collect()
}
fn normalize_mhz(raw: u32) -> u32 {
if raw >= 10_000_000 {
raw / 1_000_000 } else if raw >= 10_000 {
raw / 1000 } else {
raw }
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PerfStats {
pub device_util_pct: Option<f32>,
pub renderer_util_pct: Option<f32>,
pub tiler_util_pct: Option<f32>,
pub in_use_system_memory_bytes: Option<u64>,
}
pub fn perf_stats_from_pairs(pairs: &[(String, f64)]) -> PerfStats {
fn util(v: f64) -> Option<f32> {
(v.is_finite() && v >= 0.0).then_some(v as f32)
}
fn bytes(v: f64) -> Option<u64> {
(v.is_finite() && v >= 0.0).then_some(v as u64)
}
let mut out = PerfStats::default();
for (key, v) in pairs {
match key.as_str() {
"Device Utilization %" => out.device_util_pct = util(*v),
"Renderer Utilization %" => out.renderer_util_pct = util(*v),
"Tiler Utilization %" => out.tiler_util_pct = util(*v),
"In use system memory" => out.in_use_system_memory_bytes = bytes(*v),
_ => {}
}
}
out
}
}
#[cfg(all(feature = "apple", target_os = "macos"))]
mod metal {
use objc2::runtime::NSObjectProtocol;
use objc2::sel;
use objc2_metal::MTLDevice as _;
pub(super) struct MetalInfo {
pub name: Option<String>,
pub has_unified_memory: Option<bool>,
pub working_set_bytes: Option<u64>,
}
pub(super) fn probe() -> Option<MetalInfo> {
let dev = objc2_metal::MTLCreateSystemDefaultDevice()?;
let name = dev
.respondsToSelector(sel!(name))
.then(|| dev.name().to_string());
let has_unified_memory = dev
.respondsToSelector(sel!(hasUnifiedMemory))
.then(|| dev.hasUnifiedMemory());
let working_set_bytes = dev
.respondsToSelector(sel!(recommendedMaxWorkingSetSize))
.then(|| dev.recommendedMaxWorkingSetSize())
.filter(|&b| b > 0);
Some(MetalInfo {
name,
has_unified_memory,
working_set_bytes,
})
}
}
#[cfg(all(feature = "apple", target_os = "macos"))]
mod tier_bc {
pub(super) struct Sample {
pub util_pct: Option<f32>,
pub mem_used_bytes: Option<u64>,
pub power_mw: Option<u32>,
pub sm_clock_mhz: Option<u32>,
}
pub(super) fn sample() -> Sample {
Sample {
util_pct: None,
mem_used_bytes: None,
power_mw: None,
sm_clock_mhz: None,
}
}
}
#[cfg(all(feature = "apple", target_os = "macos"))]
mod backend_impl {
use super::{device_id_for, metal, tier_a_source_caveat, tier_bc, PROCESS_HINT};
use crate::backend::{BackendError, GpuBackend};
use crate::model::{now_ms, DeviceId, DynamicSample, ProcessSample, StaticInfo, Vendor};
pub struct AppleBackend {
id: DeviceId,
name: String,
mem_total_bytes: Option<u64>,
has_unified_memory: Option<bool>,
}
impl AppleBackend {
pub fn init() -> Result<Self, BackendError> {
let m = metal::probe().ok_or_else(|| {
BackendError::Unavailable(
"no default Metal device (Metal is this backend's floor)".into(),
)
})?;
let name = m.name.unwrap_or_else(|| "Apple GPU".to_string());
Ok(Self {
id: device_id_for(&name),
name,
mem_total_bytes: m.working_set_bytes,
has_unified_memory: m.has_unified_memory,
})
}
pub fn unified_memory(&self) -> Option<bool> {
self.has_unified_memory
}
fn check(&self, dev: &DeviceId) -> Result<(), BackendError> {
if *dev == self.id {
Ok(())
} else {
Err(BackendError::DeviceNotFound(dev.clone()))
}
}
}
impl GpuBackend for AppleBackend {
fn name(&self) -> &'static str {
"apple"
}
fn devices(&mut self) -> Vec<DeviceId> {
vec![self.id.clone()]
}
fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
self.check(dev)?;
Ok(StaticInfo {
id: self.id.clone(),
vendor: Vendor::Apple,
name: self.name.clone(),
backend: "apple".into(),
mem_total_bytes: self.mem_total_bytes,
power_limit_mw: None,
max_sm_clock_mhz: None,
temp_slowdown_c: None,
driver_version: None,
process_hint: Some(PROCESS_HINT.to_string()),
source_caveat: Some(tier_a_source_caveat(self.has_unified_memory).to_string()),
})
}
fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
self.check(dev)?;
let t = tier_bc::sample();
Ok(DynamicSample {
ts_ms: now_ms(),
util_pct: t.util_pct,
util_engine: None, mem_used_bytes: t.mem_used_bytes,
power_mw: t.power_mw,
temp_c: None, fan_pct: None, sm_clock_mhz: t.sm_clock_mhz,
mem_clock_mhz: None, encoder_pct: None,
decoder_pct: None,
throttle: None,
})
}
fn refresh_processes(
&mut self,
dev: &DeviceId,
) -> Result<Vec<ProcessSample>, BackendError> {
self.check(dev)?;
Ok(Vec::new())
}
}
}
#[cfg(all(feature = "apple", target_os = "macos"))]
pub use backend_impl::AppleBackend;
#[cfg(test)]
mod tests {
use super::parse::*;
use super::*;
fn fixture(name: &str) -> String {
let path = format!(
"{}/tests/fixtures/ioreport/{name}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("fixture {path}: {e}"))
}
#[test]
fn device_id_is_stable_slug_of_chip_name() {
assert_eq!(device_id_for("Apple M2 Max").0, "apple:m2-max");
assert_eq!(device_id_for("Apple M1").0, "apple:m1");
assert_eq!(
device_id_for("Apple Paravirtual device").0,
"apple:paravirtual-device"
);
assert_eq!(device_id_for("").0, "apple:unknown");
assert_eq!(device_id_for(" -- ").0, "apple:unknown");
assert!(
!device_id_for("Apple M3 Pro").0.contains(':')
|| device_id_for("Apple M3 Pro").0.starts_with("apple:")
);
}
#[test]
fn process_hint_blames_the_os_not_the_app() {
assert!(PROCESS_HINT.contains("macOS"));
assert!(PROCESS_HINT.contains("does not expose"));
assert!(PROCESS_HINT.contains("device-level"));
}
#[test]
fn tier_a_caveat_follows_the_hardwares_unified_memory_answer() {
for unified in [Some(true), None] {
let c = tier_a_source_caveat(unified);
assert!(c.contains("working-set budget"), "{c}");
assert!(c.contains("unified memory"), "{c}");
}
let c = tier_a_source_caveat(Some(false));
assert!(c.contains("working-set budget"), "{c}");
assert!(!c.contains("unified"), "{c}");
}
#[test]
fn energy_unit_labels_decode_and_unknown_unit_is_none() {
assert_eq!(energy_unit("mJ"), Some(EnergyUnit::Millijoules));
assert_eq!(energy_unit(" uJ "), Some(EnergyUnit::Microjoules));
assert_eq!(energy_unit("µJ"), Some(EnergyUnit::Microjoules));
assert_eq!(energy_unit("nJ"), Some(EnergyUnit::Nanojoules));
assert_eq!(energy_unit("J"), None);
assert_eq!(energy_unit("mW"), None);
assert_eq!(energy_unit(""), None);
}
#[test]
fn power_math_converts_mj_uj_nj_to_milliwatts() {
assert_eq!(
power_mw_from_energy(5_000, EnergyUnit::Millijoules, 1_000),
Some(5_000)
);
assert_eq!(
power_mw_from_energy(5_000_000, EnergyUnit::Microjoules, 1_000),
Some(5_000)
);
assert_eq!(
power_mw_from_energy(5_000_000_000, EnergyUnit::Nanojoules, 1_000),
Some(5_000)
);
assert_eq!(
power_mw_from_energy(250, EnergyUnit::Millijoules, 2_000),
Some(125)
);
}
#[test]
fn power_math_refuses_zero_interval_and_survives_huge_deltas() {
assert_eq!(
power_mw_from_energy(5_000, EnergyUnit::Millijoules, 0),
None
);
assert_eq!(
power_mw_from_energy(u64::MAX, EnergyUnit::Millijoules, 1),
Some(u32::MAX)
);
assert_eq!(
power_mw_from_energy(0, EnergyUnit::Nanojoules, 1_000),
Some(0)
);
}
#[test]
fn gpu_energy_channel_matching_handles_die_prefixes_and_skips_decoys() {
let single = parse_channels(&fixture("channels-m2.txt"));
let gpu: Vec<&ChannelDesc> = single.iter().filter(|c| is_gpu_energy_channel(c)).collect();
assert_eq!(
gpu.len(),
1,
"exactly one GPU energy channel on a single die"
);
assert_eq!(gpu[0].channel, "GPU Energy");
assert_eq!(energy_unit(&gpu[0].unit), Some(EnergyUnit::Millijoules));
let ultra = parse_channels(&fixture("channels-m2-ultra.txt"));
let dies: Vec<&str> = ultra
.iter()
.filter(|c| is_gpu_energy_channel(c))
.map(|c| c.channel.as_str())
.collect();
assert_eq!(dies, vec!["DIE_0_GPU Energy", "DIE_1_GPU Energy"]);
let nj = parse_channels(&fixture("channels-m4-nj.txt"));
let gpu_nj: Vec<&ChannelDesc> = nj.iter().filter(|c| is_gpu_energy_channel(c)).collect();
assert_eq!(gpu_nj.len(), 1);
assert_eq!(energy_unit(&gpu_nj[0].unit), Some(EnergyUnit::Nanojoules));
}
#[test]
fn real_paravirt_capture_exposes_no_tier_c_gpu_channels() {
let chans = parse_channels(&fixture("channels-paravirt-macos15.txt"));
assert_eq!(chans.len(), 116, "verbatim capture: 116 channels");
let energy: Vec<&str> = chans
.iter()
.filter(|c| is_gpu_energy_channel(c))
.map(|c| c.channel.as_str())
.collect();
assert!(
energy.is_empty(),
"paravirt guest exposes no GPU energy channel, got {energy:?}"
);
let ph: Vec<&str> = chans
.iter()
.filter(|c| is_gpu_perf_states_channel(c))
.map(|c| c.channel.as_str())
.collect();
assert!(
ph.is_empty(),
"paravirt guest exposes no GPUPH residency channel, got {ph:?}"
);
let internal: Vec<&str> = chans
.iter()
.filter(|c| c.group == "Internal Statistics")
.map(|c| c.channel.as_str())
.collect();
assert!(
internal.contains(&"In use system memory"),
"Tier B memory channel is present on the guest, got {internal:?}"
);
}
#[test]
fn gpuph_channel_requires_exact_group_and_subgroup() {
let chans = parse_channels(&fixture("channels-m2.txt"));
let ph: Vec<&ChannelDesc> = chans
.iter()
.filter(|c| is_gpu_perf_states_channel(c))
.collect();
assert_eq!(ph.len(), 1);
assert_eq!(ph[0].subgroup, "GPU Performance States");
let ultra = parse_channels(&fixture("channels-m2-ultra.txt"));
let dies: Vec<&str> = ultra
.iter()
.filter(|c| is_gpu_perf_states_channel(c))
.map(|c| c.channel.as_str())
.collect();
assert_eq!(dies, vec!["DIE_0_GPUPH", "DIE_1_GPUPH"]);
}
#[test]
fn residency_math_computes_active_fraction_from_fixture() {
let states = parse_states(&fixture("gpuph-m2.txt"));
assert_eq!(states.len(), 6, "fixture carries OFF + five P-states");
let util = util_pct_from_residency(&states).expect("fixture has ticks");
assert!((util - 40.0).abs() < 0.01, "got {util}");
}
#[test]
fn residency_math_is_none_when_total_is_zero() {
let empty: Vec<(String, u64)> = vec![];
assert_eq!(util_pct_from_residency(&empty), None);
let zeros = vec![("OFF".to_string(), 0), ("P1".to_string(), 0)];
assert_eq!(util_pct_from_residency(&zeros), None);
}
#[test]
fn idle_state_names_match_macmon_inventory() {
assert!(is_idle_state("OFF"));
assert!(is_idle_state("off"));
assert!(is_idle_state("IDLE"));
assert!(is_idle_state("IDLE2"));
assert!(!is_idle_state("P1"));
assert!(!is_idle_state("TURBO"));
}
#[test]
fn weighted_freq_weights_active_states_from_fixtures() {
let states = parse_states(&fixture("gpuph-m2.txt"));
let raw = parse_hex(&fixture("voltage-states9-m2.hex")).expect("valid hex fixture");
let dvfs = parse_voltage_states(&raw);
assert_eq!(dvfs, vec![444, 612, 808, 1064, 1398]);
assert_eq!(weighted_freq_mhz(&states, &dvfs), Some(798));
}
#[test]
fn weighted_freq_refuses_dvfs_state_count_mismatch() {
let states = parse_states(&fixture("gpuph-m2.txt"));
assert_eq!(weighted_freq_mhz(&states, &[444, 612, 808, 1064]), None);
assert_eq!(weighted_freq_mhz(&states, &[]), None);
assert_eq!(weighted_freq_mhz(&[], &[444]), None);
}
#[test]
fn weighted_freq_is_zero_when_gpu_measured_fully_idle() {
let states = vec![
("OFF".to_string(), 1_000_000u64),
("P1".to_string(), 0),
("P2".to_string(), 0),
];
assert_eq!(weighted_freq_mhz(&states, &[444, 612]), Some(0));
}
#[test]
fn voltage_states9_decoder_normalizes_hz_khz_and_drops_zero_rows() {
let raw = parse_hex(&fixture("voltage-states9-khz-synthetic.hex")).expect("valid hex");
assert_eq!(parse_voltage_states(&raw), vec![389, 722, 998]);
assert_eq!(parse_voltage_states(&[0x01, 0x02, 0x03]), Vec::<u32>::new());
assert_eq!(parse_hex("zz"), None);
assert_eq!(parse_hex("abc"), None); }
#[test]
fn perf_stats_every_key_optional_and_decoy_keys_ignored() {
assert_eq!(perf_stats_from_pairs(&[]), PerfStats::default());
let pairs = vec![
("Device Utilization %".to_string(), 37.0),
("Device Utilization at cur p-state".to_string(), 99.0),
("In use system memory".to_string(), 3_221_225_472.0),
("Alloc system memory".to_string(), 9_999_999_999.0),
("Temperature(C)".to_string(), 55.0),
];
let stats = perf_stats_from_pairs(&pairs);
assert_eq!(stats.device_util_pct, Some(37.0));
assert_eq!(stats.renderer_util_pct, None, "absent key stays None");
assert_eq!(stats.tiler_util_pct, None);
assert_eq!(stats.in_use_system_memory_bytes, Some(3 << 30));
}
#[test]
fn perf_stats_negative_values_are_garbage_not_zero() {
let pairs = vec![
("Device Utilization %".to_string(), -1.0),
("In use system memory".to_string(), -4096.0),
("Renderer Utilization %".to_string(), f64::NAN),
];
let stats = perf_stats_from_pairs(&pairs);
assert_eq!(stats.device_util_pct, None);
assert_eq!(stats.in_use_system_memory_bytes, None);
assert_eq!(stats.renderer_util_pct, None);
}
#[test]
fn channel_fixture_lines_roundtrip() {
let c = ChannelDesc {
group: "Energy Model".into(),
subgroup: String::new(),
channel: "DIE_0_GPU Energy".into(),
unit: "mJ".into(),
};
assert_eq!(ChannelDesc::from_line(&c.to_line()), Some(c.clone()));
assert_eq!(ChannelDesc::from_line("# comment"), None);
assert_eq!(ChannelDesc::from_line("state|OFF|123"), None);
assert_eq!(ChannelDesc::from_line("channel|too|few"), None);
}
#[cfg(all(feature = "apple", target_os = "macos"))]
#[test]
fn apple_backend_smoke_device_level_only() {
use crate::backend::GpuBackend;
let Ok(mut b) = AppleBackend::init() else {
return;
};
let devs = b.devices();
assert_eq!(devs.len(), 1, "Apple Silicon has exactly one GPU");
assert!(devs[0].0.starts_with("apple:"));
let info = b.static_info(&devs[0]).expect("static info for own device");
assert!(!info.name.is_empty(), "paravirt device still has a name");
assert_eq!(
info.process_hint.as_deref(),
Some(PROCESS_HINT),
"the OS-prohibition explainer must always ship"
);
assert_eq!(
info.source_caveat.as_deref(),
Some(tier_a_source_caveat(b.unified_memory())),
"the mem-total budget label must always ship (§4.1)"
);
let s = b.refresh_dynamic(&devs[0]).expect("dynamic refresh");
assert!(s.ts_ms > 0);
assert!(s.util_pct.is_none());
assert!(s.mem_used_bytes.is_none());
assert!(s.power_mw.is_none());
assert!(s.sm_clock_mhz.is_none());
assert!(s.temp_c.is_none() && s.fan_pct.is_none());
assert_eq!(s.throttle, None);
let procs = b.refresh_processes(&devs[0]).expect("process refresh");
assert!(procs.is_empty(), "per-process is OS-prohibited on macOS");
let bogus = crate::model::DeviceId("apple:not-this-one".into());
assert!(b.static_info(&bogus).is_err());
}
}