use crate::collect::model::GpuTick;
#[cfg(all(target_os = "linux", feature = "gpu-nvidia"))]
mod nvidia {
use super::GpuTick;
use nvml_wrapper::enum_wrappers::device::TemperatureSensor;
use nvml_wrapper::Nvml;
use std::sync::OnceLock;
static NVML: OnceLock<Option<Nvml>> = OnceLock::new();
fn nvml() -> Option<&'static Nvml> {
NVML.get_or_init(|| Nvml::init().ok()).as_ref()
}
pub fn discover() -> Vec<GpuTick> {
let Some(nvml) = nvml() else {
return Vec::new();
};
let count = nvml.device_count().unwrap_or(0);
(0..count)
.filter_map(|i| nvml.device_by_index(i).ok())
.map(|d| {
let mem = d.memory_info().ok();
GpuTick {
name: d.name().unwrap_or_else(|_| "NVIDIA".into()),
vendor: "NVIDIA".into(),
driver: nvml.sys_driver_version().ok(),
vram_total_bytes: mem.as_ref().map(|m| m.total),
vram_used_bytes: mem.as_ref().map(|m| m.used),
util_pct: None,
renderer_util_pct: None,
tiler_util_pct: None,
temp_c: None,
power_w: None,
live_data_hint: None,
last_submitter_pid: None,
}
})
.collect()
}
pub fn refresh(devs: &mut [GpuTick]) {
let Some(nvml) = nvml() else {
return;
};
let mut nv_idx: u32 = 0;
for dev in devs.iter_mut() {
if dev.vendor != "NVIDIA" {
continue;
}
let Ok(d) = nvml.device_by_index(nv_idx) else {
nv_idx += 1;
continue;
};
if let Ok(util) = d.utilization_rates() {
dev.util_pct = Some(util.gpu as f32);
}
if let Ok(mem) = d.memory_info() {
dev.vram_total_bytes = Some(mem.total);
dev.vram_used_bytes = Some(mem.used);
}
if let Ok(t) = d.temperature(TemperatureSensor::Gpu) {
dev.temp_c = Some(t as f32);
}
if let Ok(mw) = d.power_usage() {
dev.power_w = Some(mw as f32 / 1000.0);
}
dev.live_data_hint = None;
nv_idx += 1;
}
}
}
#[cfg(target_os = "macos")]
const HINT_MACOS_NO_IOREPORT: &str =
"IOReport unavailable — temperature + per-rail power can't be sampled";
#[cfg(target_os = "linux")]
const HINT_LINUX_NO_AMDGPU: &str =
"amdgpu driver not loaded — load it for util/VRAM/temp/power, or use the proprietary driver";
#[cfg(target_os = "linux")]
const HINT_LINUX_NVIDIA_NO_NVML: &str =
"build with --features nvidia (linked against libnvidia-ml) for util/VRAM/temp/power";
#[cfg(target_os = "linux")]
const HINT_LINUX_INTEL: &str =
"i915/xe live util needs `gpu_busy_percent` (recent kernels); per-rail power not exposed";
pub struct GpuDiscovery {
pub devices: Vec<GpuTick>,
}
impl GpuDiscovery {
pub fn new() -> Self {
Self {
devices: discover(),
}
}
#[allow(unused_mut, unused_variables)]
pub fn refresh(
&mut self,
#[cfg(target_os = "macos")] macos_tick: Option<&crate::collect::macos_sampler::MacosTick>,
) -> Vec<GpuTick> {
let mut out = self.devices.clone();
#[cfg(target_os = "macos")]
{
let stats = collect_macos_gpu_stats();
for (dev, s) in out.iter_mut().zip(stats.iter()) {
dev.util_pct = Some(s.device_util_pct);
dev.renderer_util_pct = Some(s.renderer_util_pct);
dev.tiler_util_pct = Some(s.tiler_util_pct);
if s.in_use_system_memory > 0 {
dev.vram_used_bytes = Some(s.in_use_system_memory);
}
dev.last_submitter_pid = s.last_submission_pid;
}
let gpu_power_w = macos_tick.and_then(|t| t.gpu_power_w);
let gpu_temp_c = macos_tick.and_then(|t| t.gpu_temp_c);
for dev in out.iter_mut() {
dev.power_w = gpu_power_w;
dev.temp_c = gpu_temp_c;
dev.live_data_hint = match (gpu_power_w, gpu_temp_c) {
(Some(_), Some(_)) => None,
_ if macos_tick.is_some() => None, _ => Some(HINT_MACOS_NO_IOREPORT.into()),
};
}
}
#[cfg(all(target_os = "linux", feature = "gpu-nvidia"))]
nvidia::refresh(&mut out);
#[cfg(target_os = "linux")]
for (i, dev) in out.iter_mut().enumerate() {
if let Some(util) = read_linux_busy_percent(i) {
dev.util_pct = Some(util);
dev.live_data_hint = None;
}
if dev.vendor == "AMD" {
let device_path =
std::path::PathBuf::from(format!("/sys/class/drm/card{}/device", i));
if let Some(used) = read_amdgpu_vram_bytes(&device_path.join("mem_info_vram_used"))
{
dev.vram_used_bytes = Some(used);
}
if let Some(hwmon) = find_amdgpu_hwmon_dir(&device_path) {
if let Some(t) = read_hwmon_temp_c(&hwmon.join("temp1_input")) {
dev.temp_c = Some(t);
}
if let Some(w) = read_hwmon_power_w(&hwmon.join("power1_average")) {
dev.power_w = Some(w);
}
}
if dev.util_pct.is_some()
&& dev.vram_used_bytes.is_some()
&& (dev.temp_c.is_some() || dev.power_w.is_some())
{
dev.live_data_hint = None;
}
}
}
out
}
}
#[cfg(target_os = "macos")]
fn discover() -> Vec<GpuTick> {
use std::process::Command;
let output = Command::new("system_profiler")
.args(["SPDisplaysDataType", "-json"])
.output();
let Ok(out) = output else { return Vec::new() };
let text = String::from_utf8_lossy(&out.stdout);
let Ok(parsed): Result<serde_json::Value, _> = serde_json::from_str(&text) else {
return Vec::new();
};
let Some(arr) = parsed.get("SPDisplaysDataType").and_then(|v| v.as_array()) else {
return Vec::new();
};
arr.iter()
.map(|d| {
let name = d
.get("sppci_model")
.or_else(|| d.get("_name"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown GPU")
.to_string();
let vendor = d
.get("spdisplays_vendor")
.and_then(|v| v.as_str())
.map(strip_macos_vendor_key)
.unwrap_or_else(|| "Apple".into());
let vram = d
.get("spdisplays_vram_shared")
.or_else(|| d.get("spdisplays_vram"))
.and_then(|v| v.as_str())
.and_then(parse_vram_string);
let driver = d
.get("spdisplays_metalfamily")
.or_else(|| d.get("spdisplays_mtlgpufamilysupport"))
.and_then(|v| v.as_str())
.map(String::from);
GpuTick {
name,
vendor,
driver,
vram_total_bytes: vram,
vram_used_bytes: None,
util_pct: None,
renderer_util_pct: None,
tiler_util_pct: None,
temp_c: None,
power_w: None,
live_data_hint: None,
last_submitter_pid: None,
}
})
.collect()
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Default, PartialEq)]
struct MacGpuStats {
device_util_pct: f32,
renderer_util_pct: f32,
tiler_util_pct: f32,
in_use_system_memory: u64,
alloc_system_memory: u64,
last_submission_pid: Option<u32>,
}
#[cfg(target_os = "macos")]
fn collect_macos_gpu_stats() -> Vec<MacGpuStats> {
use std::process::Command;
let Ok(out) = Command::new("ioreg")
.args(["-r", "-d", "1", "-w", "0", "-c", "IOAccelerator"])
.output()
else {
return Vec::new();
};
parse_ioreg_perf_stats(&String::from_utf8_lossy(&out.stdout))
}
#[cfg(target_os = "macos")]
fn parse_ioreg_perf_stats(text: &str) -> Vec<MacGpuStats> {
const PERF_PREFIX: &str = "\"PerformanceStatistics\" = {";
const AGC_PREFIX: &str = "\"AGCInfo\" = {";
let mut out: Vec<MacGpuStats> = Vec::new();
let mut current: Option<MacGpuStats> = None;
for line in text.lines() {
if line.trim_start().starts_with("+-o ") {
if let Some(prev) = current.take() {
out.push(prev);
}
current = Some(MacGpuStats::default());
continue;
}
if current.is_none()
&& (extract_dict_body(line, PERF_PREFIX).is_some()
|| extract_dict_body(line, AGC_PREFIX).is_some())
{
current = Some(MacGpuStats::default());
}
let Some(stats) = current.as_mut() else {
continue;
};
if let Some(body) = extract_dict_body(line, PERF_PREFIX) {
apply_perf_stats(stats, body);
} else if let Some(body) = extract_dict_body(line, AGC_PREFIX) {
apply_agc_info(stats, body);
}
}
if let Some(last) = current.take() {
out.push(last);
}
out
}
#[cfg(target_os = "macos")]
fn extract_dict_body<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
let idx = line.find(prefix)?;
let body_start = idx + prefix.len();
let rel_end = line[body_start..].find('}')?;
Some(&line[body_start..body_start + rel_end])
}
#[cfg(target_os = "macos")]
fn apply_agc_info(stats: &mut MacGpuStats, body: &str) {
for pair in body.split(',') {
let Some(eq) = pair.find('=') else { continue };
let key = pair[..eq].trim().trim_matches('"');
let val = pair[eq + 1..].trim();
if key == "fLastSubmissionPID" {
if let Ok(p) = val.parse::<u32>() {
stats.last_submission_pid = Some(p);
}
}
}
}
#[cfg(target_os = "macos")]
fn apply_perf_stats(stats: &mut MacGpuStats, body: &str) {
for pair in body.split(',') {
let Some(eq) = pair.find('=') else { continue };
let key = pair[..eq].trim().trim_matches('"');
let val = pair[eq + 1..].trim();
match key {
"Device Utilization %" => {
stats.device_util_pct = val.parse::<f32>().unwrap_or(0.0);
}
"Renderer Utilization %" => {
stats.renderer_util_pct = val.parse::<f32>().unwrap_or(0.0);
}
"Tiler Utilization %" => {
stats.tiler_util_pct = val.parse::<f32>().unwrap_or(0.0);
}
"In use system memory" => {
stats.in_use_system_memory = val.parse::<u64>().unwrap_or(0);
}
"Alloc system memory" => {
stats.alloc_system_memory = val.parse::<u64>().unwrap_or(0);
}
_ => {}
}
}
}
#[cfg(target_os = "linux")]
fn discover() -> Vec<GpuTick> {
use std::fs;
let mut out = Vec::new();
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
return out;
};
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("card") || name_str.contains('-') {
continue;
}
let card_path = entry.path();
let device_path = card_path.join("device");
let vendor_id = fs::read_to_string(device_path.join("vendor"))
.ok()
.map(|s| s.trim().to_string());
let device_id = fs::read_to_string(device_path.join("device"))
.ok()
.map(|s| s.trim().to_string());
let vendor = match vendor_id.as_deref() {
Some("0x10de") => "NVIDIA",
Some("0x1002") => "AMD",
Some("0x8086") => "Intel",
_ => "Unknown",
}
.to_string();
let name = format!(
"{} {}",
vendor,
device_id.unwrap_or_else(|| "Unknown".into())
);
let vram_total_bytes = if vendor == "AMD" {
read_amdgpu_vram_bytes(&device_path.join("mem_info_vram_total"))
} else {
None
};
let live_data_hint = match vendor.as_str() {
"AMD" => None, "NVIDIA" => Some(HINT_LINUX_NVIDIA_NO_NVML.into()),
"Intel" => Some(HINT_LINUX_INTEL.into()),
_ => Some(HINT_LINUX_NO_AMDGPU.into()),
};
out.push(GpuTick {
name,
vendor,
driver: None,
vram_total_bytes,
vram_used_bytes: None,
util_pct: None,
renderer_util_pct: None,
tiler_util_pct: None,
temp_c: None,
power_w: None,
live_data_hint,
last_submitter_pid: None,
});
}
#[cfg(feature = "gpu-nvidia")]
{
let nv = nvidia::discover();
if !nv.is_empty() {
out.retain(|g| g.vendor != "NVIDIA");
out.extend(nv);
}
}
out
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn discover() -> Vec<GpuTick> {
Vec::new()
}
#[cfg(target_os = "linux")]
fn read_linux_busy_percent(card_idx: usize) -> Option<f32> {
let path = format!("/sys/class/drm/card{}/device/gpu_busy_percent", card_idx);
let s = std::fs::read_to_string(path).ok()?;
s.trim().parse::<f32>().ok()
}
#[cfg(any(target_os = "linux", test))]
fn read_amdgpu_vram_bytes(path: &std::path::Path) -> Option<u64> {
std::fs::read_to_string(path)
.ok()?
.trim()
.parse::<u64>()
.ok()
}
#[cfg(any(target_os = "linux", test))]
fn find_amdgpu_hwmon_dir(device_path: &std::path::Path) -> Option<std::path::PathBuf> {
let hwmon_root = device_path.join("hwmon");
let entries = std::fs::read_dir(&hwmon_root).ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with("hwmon") {
return Some(entry.path());
}
}
None
}
#[cfg(any(target_os = "linux", test))]
fn read_hwmon_temp_c(path: &std::path::Path) -> Option<f32> {
let raw: i64 = std::fs::read_to_string(path).ok()?.trim().parse().ok()?;
Some(raw as f32 / 1000.0)
}
#[cfg(any(target_os = "linux", test))]
fn read_hwmon_power_w(path: &std::path::Path) -> Option<f32> {
let raw: u64 = std::fs::read_to_string(path).ok()?.trim().parse().ok()?;
Some(raw as f32 / 1_000_000.0)
}
#[cfg(target_os = "macos")]
fn parse_vram_string(s: &str) -> Option<u64> {
let parts: Vec<&str> = s.split_whitespace().collect();
let n: f64 = parts.first()?.parse().ok()?;
let mult: u64 = match parts.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("GB") => 1024 * 1024 * 1024,
Some("MB") => 1024 * 1024,
Some("KB") => 1024,
_ => 1,
};
Some((n * mult as f64) as u64)
}
#[cfg(target_os = "macos")]
fn strip_macos_vendor_key(s: &str) -> String {
s.strip_prefix("sppci_vendor_")
.unwrap_or(s)
.trim_start_matches("0x")
.to_string()
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "macos")]
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn vram_string_handles_units() {
assert_eq!(parse_vram_string("16 GB"), Some(16 * 1024 * 1024 * 1024));
assert_eq!(parse_vram_string("8192 MB"), Some(8192 * 1024 * 1024));
assert_eq!(parse_vram_string("512 KB"), Some(512 * 1024));
assert_eq!(parse_vram_string("4 gb"), Some(4 * 1024 * 1024 * 1024));
assert_eq!(
parse_vram_string("1.5 GB"),
Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
);
}
#[cfg(target_os = "macos")]
#[test]
fn vram_string_no_unit_treated_as_bytes() {
assert_eq!(parse_vram_string("1024"), Some(1024));
}
#[cfg(target_os = "macos")]
#[test]
fn vram_string_garbage_returns_none() {
assert_eq!(parse_vram_string(""), None);
assert_eq!(parse_vram_string("not a number"), None);
}
#[cfg(target_os = "macos")]
#[test]
fn strips_sppci_vendor_prefix() {
assert_eq!(strip_macos_vendor_key("sppci_vendor_Apple"), "Apple");
assert_eq!(strip_macos_vendor_key("sppci_vendor_AMD"), "AMD");
}
#[cfg(target_os = "macos")]
#[test]
fn strips_hex_vendor_id() {
assert_eq!(strip_macos_vendor_key("0x10de"), "10de");
assert_eq!(strip_macos_vendor_key("0x1002"), "1002");
}
#[cfg(target_os = "macos")]
#[test]
fn passes_through_unknown_format() {
assert_eq!(strip_macos_vendor_key("Apple"), "Apple");
assert_eq!(strip_macos_vendor_key("NVIDIA Corp"), "NVIDIA Corp");
}
#[cfg(target_os = "macos")]
#[test]
fn parses_real_perf_stats_line() {
let sample = r#"
+-o AGXAcceleratorG15X <class AGXAcceleratorG15X, id 0x100000481, ...>
{
"model" = "Apple M3 Pro"
"PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=16749051904,"Tiler Utilization %"=7,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=11,"TiledSceneBytes"=1441792,"Device Utilization %"=16,"SplitSceneCount"=0,"Allocated PB Size"=89915392,"In use system memory"=568164352}
}
"#;
let stats = parse_ioreg_perf_stats(sample);
assert_eq!(stats.len(), 1);
let s = &stats[0];
assert_eq!(s.device_util_pct as i32, 16);
assert_eq!(s.renderer_util_pct as i32, 11);
assert_eq!(s.tiler_util_pct as i32, 7);
assert_eq!(s.in_use_system_memory, 568_164_352);
assert_eq!(s.alloc_system_memory, 16_749_051_904);
}
#[cfg(target_os = "macos")]
#[test]
fn handles_multiple_accelerators() {
let sample = r#"
+-o A
"PerformanceStatistics" = {"Device Utilization %"=10,"In use system memory"=100}
+-o B
"PerformanceStatistics" = {"Device Utilization %"=90,"In use system memory"=200}
"#;
let stats = parse_ioreg_perf_stats(sample);
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].device_util_pct as i32, 10);
assert_eq!(stats[1].device_util_pct as i32, 90);
}
#[cfg(target_os = "macos")]
#[test]
fn pulls_last_submission_pid_from_agc_info() {
let sample = r#"
+-o AGXAcceleratorG15X
"AGCInfo" = {"fLastSubmissionPID"=373,"fSubmissionsSinceLastCheck"=0,"fBusyCount"=0}
"PerformanceStatistics" = {"Device Utilization %"=42,"In use system memory"=100}
"#;
let stats = parse_ioreg_perf_stats(sample);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].last_submission_pid, Some(373));
assert_eq!(stats[0].device_util_pct as i32, 42);
}
#[cfg(target_os = "macos")]
#[test]
fn missing_agc_info_leaves_pid_none() {
let sample = r#"
+-o AGX
"PerformanceStatistics" = {"Device Utilization %"=10}
"#;
let stats = parse_ioreg_perf_stats(sample);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].last_submission_pid, None);
}
#[cfg(target_os = "macos")]
#[test]
fn agc_info_attribution_aligns_per_block() {
let sample = r#"
+-o A
"PerformanceStatistics" = {"Device Utilization %"=10}
+-o B
"AGCInfo" = {"fLastSubmissionPID"=999}
"PerformanceStatistics" = {"Device Utilization %"=90}
"#;
let stats = parse_ioreg_perf_stats(sample);
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].last_submission_pid, None);
assert_eq!(stats[1].last_submission_pid, Some(999));
}
#[cfg(target_os = "macos")]
#[test]
fn no_perf_stats_yields_empty_vec() {
assert!(parse_ioreg_perf_stats("nothing useful here").is_empty());
assert!(parse_ioreg_perf_stats("").is_empty());
}
#[cfg(target_os = "macos")]
#[test]
fn missing_fields_default_to_zero() {
let sample = r#""PerformanceStatistics" = {"Device Utilization %"=42}"#;
let stats = parse_ioreg_perf_stats(sample);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].device_util_pct as i32, 42);
assert_eq!(stats[0].renderer_util_pct, 0.0);
assert_eq!(stats[0].in_use_system_memory, 0);
}
use super::{
find_amdgpu_hwmon_dir, read_amdgpu_vram_bytes, read_hwmon_power_w, read_hwmon_temp_c,
};
use std::fs;
#[test]
fn amdgpu_vram_parses_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mem_info_vram_total");
fs::write(&path, "17163091968\n").unwrap();
assert_eq!(read_amdgpu_vram_bytes(&path), Some(17_163_091_968));
}
#[test]
fn amdgpu_vram_missing_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read_amdgpu_vram_bytes(&dir.path().join("missing")), None);
}
#[test]
fn amdgpu_vram_garbage_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mem_info_vram_total");
fs::write(&path, "not a number").unwrap();
assert_eq!(read_amdgpu_vram_bytes(&path), None);
}
#[test]
fn finds_first_hwmon_subdir() {
let dir = tempfile::tempdir().unwrap();
let hwmon0 = dir.path().join("hwmon").join("hwmon3");
fs::create_dir_all(&hwmon0).unwrap();
let found = find_amdgpu_hwmon_dir(dir.path()).unwrap();
assert_eq!(found, hwmon0);
}
#[test]
fn skips_non_hwmon_entries_in_hwmon_dir() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("hwmon").join("hwmon2")).unwrap();
let found = find_amdgpu_hwmon_dir(dir.path()).unwrap();
assert!(found
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("hwmon"));
}
#[test]
fn no_hwmon_dir_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(find_amdgpu_hwmon_dir(dir.path()), None);
}
#[test]
fn hwmon_temp_converts_millicelsius() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("temp1_input");
fs::write(&path, "65500\n").unwrap();
assert_eq!(read_hwmon_temp_c(&path), Some(65.5));
}
#[test]
fn hwmon_power_converts_microwatts() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("power1_average");
fs::write(&path, "85000000\n").unwrap();
assert_eq!(read_hwmon_power_w(&path), Some(85.0));
}
#[test]
fn hwmon_power_zero_passes_through() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("power1_average");
fs::write(&path, "0\n").unwrap();
assert_eq!(read_hwmon_power_w(&path), Some(0.0));
}
}