use std::process::Command;
use std::time::Instant;
use anyhow::{Context, Result, anyhow, bail};
use crate::backend::{GpuBackend, require_devices};
use crate::model::{GpuInfo, GpuSample};
pub struct AppleBackend {
devices: Vec<GpuInfo>,
source: AppleMetricSource,
}
#[derive(Debug, Clone, Copy, Default)]
struct AppleMetricSource {
ioreg: bool,
powermetrics: bool,
unified_memory_total_bytes: Option<u64>,
}
impl AppleMetricSource {
fn label(self) -> &'static str {
match (self.ioreg, self.powermetrics) {
(true, true) => "Apple Silicon ioreg+powermetrics",
(true, false) => "Apple Silicon ioreg",
(false, true) => "Apple Silicon powermetrics",
(false, false) => "Apple Silicon",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct AppleMetrics {
gpu_util_percent: Option<f64>,
mem_util_percent: Option<f64>,
vram_used_bytes: Option<u64>,
vram_total_bytes: Option<u64>,
power_watts: Option<f64>,
graphics_clock_mhz: Option<f64>,
}
impl AppleMetrics {
fn has_any_value(self) -> bool {
self.gpu_util_percent.is_some()
|| self.mem_util_percent.is_some()
|| self.vram_used_bytes.is_some()
|| self.vram_total_bytes.is_some()
|| self.power_watts.is_some()
|| self.graphics_clock_mhz.is_some()
}
fn fill_missing_from(&mut self, other: Self) {
self.gpu_util_percent = self.gpu_util_percent.or(other.gpu_util_percent);
self.mem_util_percent = self.mem_util_percent.or(other.mem_util_percent);
self.vram_used_bytes = self.vram_used_bytes.or(other.vram_used_bytes);
self.vram_total_bytes = self.vram_total_bytes.or(other.vram_total_bytes);
self.power_watts = self.power_watts.or(other.power_watts);
self.graphics_clock_mhz = self.graphics_clock_mhz.or(other.graphics_clock_mhz);
}
fn fill_unified_memory_total(&mut self, total_bytes: Option<u64>) {
if self.vram_used_bytes.is_some() && self.vram_total_bytes.is_none() {
self.vram_total_bytes = total_bytes;
}
if self.mem_util_percent.is_none() {
self.mem_util_percent = match (self.vram_used_bytes, self.vram_total_bytes) {
(Some(used), Some(total)) if total > 0 => {
Some((used as f64 / total as f64) * 100.0)
}
_ => None,
};
}
}
}
impl AppleBackend {
pub fn new() -> Result<Self> {
if !cfg!(target_os = "macos") {
bail!("Apple Silicon metrics are only available on macOS");
}
if !is_apple_silicon() {
bail!("host does not appear to be Apple Silicon");
}
let source = select_metric_source()?;
let devices = vec![GpuInfo {
id: 0,
backend_index: 0,
name: apple_gpu_name().unwrap_or_else(|| "Apple Silicon GPU".to_owned()),
uuid: None,
}];
require_devices(&devices, "Apple Silicon")?;
Ok(Self { devices, source })
}
}
impl GpuBackend for AppleBackend {
fn label(&self) -> &str {
self.source.label()
}
fn devices(&self) -> &[GpuInfo] {
&self.devices
}
fn sample(&mut self) -> Result<Vec<GpuSample>> {
let at = Instant::now();
let metrics = self.sample_metrics()?;
Ok(vec![GpuSample {
gpu_id: 0,
at,
gpu_util_percent: metrics.gpu_util_percent,
mem_util_percent: metrics.mem_util_percent,
vram_used_bytes: metrics.vram_used_bytes,
vram_total_bytes: metrics.vram_total_bytes,
power_watts: metrics.power_watts,
power_limit_watts: None,
temperature_celsius: None,
fan_percent: None,
graphics_clock_mhz: metrics.graphics_clock_mhz,
memory_clock_mhz: None,
compute_processes: None,
processes: Vec::new(),
}])
}
}
impl AppleBackend {
fn sample_metrics(&self) -> Result<AppleMetrics> {
let mut metrics = AppleMetrics::default();
let mut errors = Vec::new();
if self.source.ioreg {
match sample_ioreg() {
Ok(sample) => metrics.fill_missing_from(sample),
Err(error) => errors.push(format!("ioreg: {error:#}")),
}
}
if self.source.powermetrics {
match sample_powermetrics() {
Ok(sample) => metrics.fill_missing_from(sample),
Err(error) => errors.push(format!("powermetrics: {error:#}")),
}
}
metrics.fill_unified_memory_total(self.source.unified_memory_total_bytes);
if metrics.has_any_value() {
return Ok(metrics);
}
Err(anyhow!(
"Apple Silicon GPU metrics sampling failed: {}",
errors.join("; ")
))
}
}
fn is_apple_silicon() -> bool {
command_stdout("uname", &["-m"])
.map(|arch| arch.trim() == "arm64")
.unwrap_or(false)
&& command_stdout("sysctl", &["-n", "machdep.cpu.brand_string"])
.map(|brand| brand.to_ascii_lowercase().contains("apple"))
.unwrap_or(false)
}
fn apple_gpu_name() -> Option<String> {
let output = command_stdout("system_profiler", &["SPDisplaysDataType"]).ok()?;
output.lines().find_map(|line| {
let value = line.trim().strip_prefix("Chipset Model:")?.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
fn select_metric_source() -> Result<AppleMetricSource> {
let mut errors = Vec::new();
let mut source = AppleMetricSource {
unified_memory_total_bytes: system_memory_bytes(),
..AppleMetricSource::default()
};
match sample_ioreg() {
Ok(metrics) if metrics.has_any_value() => source.ioreg = true,
Ok(_) => errors.push("ioreg did not report GPU counters".to_owned()),
Err(error) => errors.push(format!("ioreg: {error:#}")),
}
match sample_powermetrics() {
Ok(metrics) if metrics.has_any_value() => source.powermetrics = true,
Ok(_) => errors
.push("powermetrics did not report GPU utilization, power, or frequency".to_owned()),
Err(error) => errors.push(format!(
"powermetrics: {error:#}; this sampler usually requires administrator privileges"
)),
}
if source.ioreg || source.powermetrics {
return Ok(source);
}
Err(anyhow!(
"no Apple Silicon GPU metrics source is available; tried {}",
errors.join("; ")
))
}
fn sample_ioreg() -> Result<AppleMetrics> {
let mut errors = Vec::new();
for class_name in ["IOAccelerator", "AGXAccelerator"] {
match command_stdout("ioreg", &["-r", "-d", "1", "-w", "0", "-c", class_name]) {
Ok(output) => {
let metrics = parse_ioreg(&output);
if metrics.has_any_value() {
return Ok(metrics);
}
errors.push(format!("{class_name} contained no recognized GPU counters"));
}
Err(error) => errors.push(format!("{class_name}: {error:#}")),
}
}
Err(anyhow!("{}", errors.join("; ")))
}
fn sample_powermetrics() -> Result<AppleMetrics> {
let output = command_stdout(
"powermetrics",
&["--samplers", "gpu_power", "-n", "1", "-i", "100"],
)?;
Ok(parse_powermetrics(&output))
}
fn parse_ioreg(output: &str) -> AppleMetrics {
let gpu_util_percent = max_numeric_value(
output,
&[
"Device Utilization %",
"GPU Core Utilization",
"GPU HW active residency",
"Renderer Utilization %",
"Tiler Utilization %",
],
);
let vram_used_bytes = ioreg_memory_used_bytes(output);
let vram_free_bytes = ioreg_memory_free_bytes(output);
let vram_total_bytes = ioreg_memory_total_bytes(output).or_else(|| {
let used = vram_used_bytes?;
let free = vram_free_bytes?;
used.checked_add(free)
});
let mem_util_percent = match (vram_used_bytes, vram_total_bytes) {
(Some(used), Some(total)) if total > 0 => Some((used as f64 / total as f64) * 100.0),
_ => None,
};
AppleMetrics {
gpu_util_percent,
mem_util_percent,
vram_used_bytes,
vram_total_bytes,
power_watts: None,
graphics_clock_mhz: max_numeric_value(output, &["GPU HW active frequency"]),
}
}
fn ioreg_memory_used_bytes(output: &str) -> Option<u64> {
max_u64_value(
output,
&[
"vramUsedBytes",
"VRAM Used Bytes",
"GPU Memory Used",
"In use memory",
],
)
.or_else(|| {
sum_u64_values(
output,
&[
"In use video memory",
"In use system memory",
"IOSurface memory",
],
)
})
.or_else(|| {
sum_u64_values(
output,
&[
"Allocated video memory",
"Allocated system memory",
"Alloc video memory",
"Alloc system memory",
],
)
})
}
fn ioreg_memory_free_bytes(output: &str) -> Option<u64> {
max_u64_value(output, &["vramFreeBytes", "VRAM Free Bytes"])
.or_else(|| sum_u64_values(output, &["Free video memory", "Free system memory"]))
}
fn ioreg_memory_total_bytes(output: &str) -> Option<u64> {
max_u64_value(
output,
&[
"vramTotalBytes",
"VRAM Total Bytes",
"GPU Memory Total",
"Device Memory Size",
"VRAM,totalSize",
"VRAM,totalsize",
],
)
}
fn parse_powermetrics(output: &str) -> AppleMetrics {
let mut metrics = AppleMetrics::default();
for line in output.lines() {
let lower = line.to_ascii_lowercase();
if !lower.contains("gpu") {
continue;
}
if lower.contains("power") {
metrics.power_watts = parse_power_watts(line).or(metrics.power_watts);
} else if lower.contains("active") && lower.contains('%') {
metrics.gpu_util_percent = first_number(line).or(metrics.gpu_util_percent);
} else if lower.contains("frequency") || lower.contains("freq") {
metrics.graphics_clock_mhz = parse_frequency_mhz(line).or(metrics.graphics_clock_mhz);
}
}
metrics
}
fn parse_power_watts(line: &str) -> Option<f64> {
let number = first_number(line)?;
let lower = line.to_ascii_lowercase();
if lower.contains("mw") {
Some(number / 1000.0)
} else {
Some(number)
}
}
fn parse_frequency_mhz(line: &str) -> Option<f64> {
let number = first_number(line)?;
let lower = line.to_ascii_lowercase();
if lower.contains("ghz") {
Some(number * 1000.0)
} else {
Some(number)
}
}
fn first_number(line: &str) -> Option<f64> {
let trimmed = line
.trim_start_matches(|ch: char| !ch.is_ascii_digit() && ch != '.' && ch != '-' && ch != '+');
if let Some(rest) = trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
{
let hex = rest
.chars()
.take_while(|ch| ch.is_ascii_hexdigit())
.collect::<String>();
if !hex.is_empty() {
return u64::from_str_radix(&hex, 16).ok().map(|value| value as f64);
}
}
let mut number = String::new();
for (index, ch) in trimmed.chars().enumerate() {
let sign = index == 0 && (ch == '-' || ch == '+');
if ch.is_ascii_digit() || ch == '.' || sign {
number.push(ch);
} else {
break;
}
}
number.parse().ok()
}
fn max_numeric_value(text: &str, keys: &[&str]) -> Option<f64> {
keys.iter()
.flat_map(|key| numeric_values_for_key(text, key))
.reduce(f64::max)
}
fn max_u64_value(text: &str, keys: &[&str]) -> Option<u64> {
max_numeric_value(text, keys).map(|value| value.round() as u64)
}
fn sum_u64_values(text: &str, keys: &[&str]) -> Option<u64> {
let mut total = 0_u64;
let mut found = false;
for key in keys {
if let Some(value) = max_u64_value(text, &[*key]) {
total = total.saturating_add(value);
found = true;
}
}
found.then_some(total)
}
fn numeric_values_for_key(text: &str, key: &str) -> Vec<f64> {
let mut values = Vec::new();
let mut offset = 0;
while let Some(relative_start) = text[offset..].find(key) {
let after_key_start = offset + relative_start + key.len();
let after_key = &text[after_key_start..];
if let Some(equals_offset) = after_key.find('=') {
let before_equals = &after_key[..equals_offset];
if before_equals
.chars()
.all(|ch| ch.is_whitespace() || ch == '"')
&& let Some(value) = first_number(&after_key[equals_offset + 1..])
{
values.push(value);
}
}
offset = after_key_start;
}
values
}
fn command_stdout(program: &str, args: &[&str]) -> Result<String> {
let output = Command::new(program)
.args(args)
.output()
.with_context(|| format!("failed to execute {program}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("{program} failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn system_memory_bytes() -> Option<u64> {
command_stdout("sysctl", &["-n", "hw.memsize"])
.ok()?
.trim()
.parse()
.ok()
}
#[cfg(test)]
mod tests {
use super::{parse_frequency_mhz, parse_ioreg, parse_power_watts, parse_powermetrics};
#[test]
fn parses_powermetrics_gpu_lines() {
let metrics = parse_powermetrics(
"GPU Power: 1420 mW\nGPU active residency: 32.5%\nGPU HW active frequency: 1.2 GHz\n",
);
assert_eq!(metrics.power_watts, Some(1.42));
assert_eq!(metrics.gpu_util_percent, Some(32.5));
assert_eq!(metrics.graphics_clock_mhz, Some(1200.0));
}
#[test]
fn parses_ioreg_performance_statistics() {
let metrics = parse_ioreg(
r#"
"PerformanceStatistics" = {
"Device Utilization %"=37,
"Renderer Utilization %"=12,
"In use system memory"=1048576,
"vramUsedBytes"=2097152,
"vramFreeBytes"=6291456
}
"#,
);
assert_eq!(metrics.gpu_util_percent, Some(37.0));
assert_eq!(metrics.vram_used_bytes, Some(2_097_152));
assert_eq!(metrics.vram_total_bytes, Some(8_388_608));
assert_eq!(metrics.mem_util_percent, Some(25.0));
}
#[test]
fn parses_ioreg_unified_memory_without_direct_vram_keys() {
let metrics = parse_ioreg(
r#"
"PerformanceStatistics" = {
"Device Utilization %"=16,
"In use video memory"=314572800,
"In use system memory"=104857600
}
"#,
);
assert_eq!(metrics.gpu_util_percent, Some(16.0));
assert_eq!(metrics.vram_used_bytes, Some(419_430_400));
assert_eq!(metrics.vram_total_bytes, None);
assert_eq!(metrics.mem_util_percent, None);
}
#[test]
fn parses_ioreg_hex_memory_values() {
let metrics = parse_ioreg(
r#"
"PerformanceStatistics" = {
"vramUsedBytes"=0x100000,
"vramTotalBytes"=0x400000
}
"#,
);
assert_eq!(metrics.vram_used_bytes, Some(1_048_576));
assert_eq!(metrics.vram_total_bytes, Some(4_194_304));
assert_eq!(metrics.mem_util_percent, Some(25.0));
}
#[test]
fn parses_power_units() {
assert_eq!(parse_power_watts("GPU Power: 12 W"), Some(12.0));
assert_eq!(parse_power_watts("GPU Power: 500 mW"), Some(0.5));
}
#[test]
fn parses_frequency_units() {
assert_eq!(parse_frequency_mhz("GPU frequency: 750 MHz"), Some(750.0));
assert_eq!(parse_frequency_mhz("GPU frequency: 1.5 GHz"), Some(1500.0));
}
}