use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use crate::apple::ioregistry::{self, AcceleratorInfo};
use crate::apple::metrics::{
DvfsTable, active_residency_pct, clamp_pct, energy_delta_to_watts, parse_dvfs_table,
residency_weighted_clock_mhz,
};
use crate::gpu::{GpuBackend, GpuDeviceSnapshot, GpuVendor, GpusSnapshot};
use crate::gpu_engine::{GpuEngine, GpuError, MACOS_UNSUPPORTED_GPU_DETAIL};
use super::ioreport::IoReportSampler;
pub struct AppleEngine {
inner: Arc<Inner>,
}
struct Inner {
unified_memory_bytes: Option<u64>,
dvfs: Option<DvfsTable>,
sampler: Mutex<Option<IoReportSampler>>,
}
impl AppleEngine {
pub fn connect() -> Result<Self, GpuError> {
let accelerators = ioregistry::accelerators();
if accelerators.is_empty() {
return Err(GpuError::DriverUnavailable {
vendor: "Apple",
reason: "no IOAccelerator service in the IORegistry".into(),
});
}
if !accelerators.iter().any(AcceleratorInfo::is_apple_gpu) {
return Err(GpuError::Unsupported(MACOS_UNSUPPORTED_GPU_DETAIL));
}
let dvfs = ioregistry::gpu_dvfs_blob()
.as_deref()
.and_then(parse_dvfs_table);
if dvfs.is_none() {
tracing::debug!(
target: "muxtop::gpu",
"GPU DVFS table unreadable; clocks will render as unavailable"
);
}
let sampler = match IoReportSampler::open() {
Ok(sampler) => Some(sampler),
Err(err) => {
tracing::debug!(
target: "muxtop::gpu",
error = %err,
"IOReport unavailable; power and clocks will render as unavailable"
);
None
}
};
Ok(Self {
inner: Arc::new(Inner {
unified_memory_bytes: ioregistry::unified_memory_bytes(),
dvfs,
sampler: Mutex::new(sampler),
}),
})
}
}
impl Inner {
fn collect(&self) -> GpusSnapshot {
let interval = self
.sampler
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_mut()
.and_then(IoReportSampler::sample);
let power_watts = interval.as_ref().and_then(|report| {
let (delta, unit) = report.energy.as_ref()?;
energy_delta_to_watts(*delta, unit, report.elapsed)
});
let residency = interval
.as_ref()
.zip(self.dvfs.as_ref())
.map(|(report, table)| (report.residencies.as_slice(), table));
let graphics_clock_mhz =
residency.and_then(|(res, table)| residency_weighted_clock_mhz(res, table));
let residency_pct = residency.and_then(|(res, table)| active_residency_pct(res, table));
let devices: Vec<GpuDeviceSnapshot> = ioregistry::accelerators()
.into_iter()
.filter(AcceleratorInfo::is_apple_gpu)
.enumerate()
.map(|(index, info)| DeviceInputs {
index: index as u32,
info,
power_watts,
graphics_clock_mhz,
residency_pct,
unified_memory_bytes: self.unified_memory_bytes,
})
.map(build_device)
.collect();
if devices.is_empty() {
return GpusSnapshot::unavailable_with("the Apple GPU disappeared from the IORegistry");
}
GpusSnapshot {
backends: vec![GpuBackend::AppleIoReport],
available: true,
devices,
processes: Vec::new(),
detail: String::new(),
}
}
}
struct DeviceInputs {
index: u32,
info: AcceleratorInfo,
power_watts: Option<f32>,
graphics_clock_mhz: Option<u32>,
residency_pct: Option<f32>,
unified_memory_bytes: Option<u64>,
}
fn build_device(input: DeviceInputs) -> GpuDeviceSnapshot {
let DeviceInputs {
index,
info,
power_watts,
graphics_clock_mhz,
residency_pct,
unified_memory_bytes,
} = input;
GpuDeviceSnapshot {
index,
vendor: GpuVendor::Apple,
backend: GpuBackend::AppleIoReport,
name: device_name(&info),
bus_id: String::new(),
driver_version: info.driver_version.clone(),
utilization_pct: info.utilization_pct.map(clamp_pct).or(residency_pct),
mem_utilization_pct: None,
mem_used_bytes: info.in_use_memory_bytes,
mem_total_bytes: unified_memory_bytes,
temperature_c: None,
power_watts,
power_limit_watts: None,
graphics_clock_mhz,
memory_clock_mhz: None,
fan_pct: None,
encoder_pct: None,
decoder_pct: None,
supports_process_stats: false,
}
}
fn device_name(info: &AcceleratorInfo) -> String {
let base = info
.model
.clone()
.or_else(|| info.class.clone())
.unwrap_or_else(|| "Apple GPU".to_string());
match info.gpu_core_count {
Some(cores) if cores > 0 => format!("{base} ({cores}-core GPU)"),
_ => base,
}
}
#[async_trait]
impl GpuEngine for AppleEngine {
async fn snapshot(&self) -> Result<GpusSnapshot, GpuError> {
let inner = Arc::clone(&self.inner);
tokio::task::spawn_blocking(move || inner.collect())
.await
.map_err(|e| {
GpuError::Query(format!(
"Apple GPU collection task panicked or was cancelled: {e}"
))
})
}
fn backend(&self) -> GpuBackend {
GpuBackend::AppleIoReport
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(model: Option<&str>, class: Option<&str>, cores: Option<u32>) -> AcceleratorInfo {
AcceleratorInfo {
model: model.map(Into::into),
class: class.map(Into::into),
gpu_core_count: cores,
..AcceleratorInfo::default()
}
}
fn inputs(info: AcceleratorInfo) -> DeviceInputs {
DeviceInputs {
index: 0,
info,
power_watts: None,
graphics_clock_mhz: None,
residency_pct: None,
unified_memory_bytes: Some(8 * 1024 * 1024 * 1024),
}
}
#[test]
fn device_name_carries_the_core_count() {
let named = device_name(&info(Some("Apple M3"), Some("AGXAcceleratorG15G"), Some(8)));
assert_eq!(named, "Apple M3 (8-core GPU)");
}
#[test]
fn device_name_falls_back_to_the_driver_class() {
assert_eq!(
device_name(&info(None, Some("AGXAcceleratorG16P"), None)),
"AGXAcceleratorG16P"
);
}
#[test]
fn device_name_never_ends_up_empty() {
assert_eq!(device_name(&info(None, None, None)), "Apple GPU");
}
#[test]
fn device_name_omits_a_zero_core_count() {
assert_eq!(
device_name(&info(Some("Apple M3"), None, Some(0))),
"Apple M3"
);
}
#[test]
fn only_agx_drivers_count_as_apple_gpus() {
assert!(info(None, Some("AGXAcceleratorG15G"), None).is_apple_gpu());
assert!(!info(None, Some("AMDRadeonX6000"), None).is_apple_gpu());
assert!(!info(None, Some("IntelAccelerator"), None).is_apple_gpu());
assert!(!info(None, None, None).is_apple_gpu());
}
#[test]
fn unreportable_metrics_stay_none() {
let device = build_device(inputs(info(
Some("Apple M3"),
Some("AGXAcceleratorG15G"),
Some(8),
)));
assert_eq!(device.temperature_c, None);
assert_eq!(device.power_limit_watts, None);
assert_eq!(device.memory_clock_mhz, None);
assert_eq!(device.mem_utilization_pct, None);
assert_eq!(device.fan_pct, None);
assert_eq!(device.encoder_pct, None);
assert_eq!(device.decoder_pct, None);
assert!(!device.supports_process_stats);
assert!(device.bus_id.is_empty(), "there is no PCI bus to report");
}
#[test]
fn utilisation_prefers_the_driver_over_residency() {
let mut acc = info(Some("Apple M3"), Some("AGXAcceleratorG15G"), Some(8));
acc.utilization_pct = Some(62);
let mut input = inputs(acc);
input.residency_pct = Some(24.8);
let device = build_device(input);
assert_eq!(device.utilization_pct, Some(62.0));
}
#[test]
fn utilisation_falls_back_to_unparked_residency() {
let mut input = inputs(info(Some("Apple M3"), Some("AGXAcceleratorG15G"), Some(8)));
input.residency_pct = Some(24.5);
let device = build_device(input);
assert_eq!(device.utilization_pct, Some(24.5));
}
#[test]
fn utilisation_is_clamped_to_the_gauge_range() {
let mut acc = info(Some("Apple M3"), Some("AGXAcceleratorG15G"), Some(8));
acc.utilization_pct = Some(120);
let device = build_device(inputs(acc));
assert_eq!(device.utilization_pct, Some(100.0));
}
#[test]
fn memory_is_reported_against_the_unified_pool() {
let mut acc = info(Some("Apple M3"), Some("AGXAcceleratorG15G"), Some(8));
acc.in_use_memory_bytes = Some(2 * 1024 * 1024 * 1024);
let device = build_device(inputs(acc));
assert_eq!(device.mem_total_bytes, Some(8 * 1024 * 1024 * 1024));
let pct = device.mem_pct().expect("both sides known");
assert!((pct - 25.0).abs() < 0.01, "expected 25%, got {pct}");
}
#[test]
fn a_device_is_tagged_apple_on_both_axes() {
let device = build_device(inputs(info(
Some("Apple M3"),
Some("AGXAcceleratorG15G"),
Some(8),
)));
assert_eq!(device.vendor, GpuVendor::Apple);
assert_eq!(device.backend, GpuBackend::AppleIoReport);
}
}