gpu-histop 0.1.0

High-resolution GPU history monitor for NVIDIA, AMDGPU, and Apple Silicon
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;

use anyhow::{Context, Result};

use crate::backend::{GpuBackend, require_devices};
use crate::model::{GpuInfo, GpuSample};

pub struct AmdGpuBackend {
    adapters: Vec<AmdGpuAdapter>,
    devices: Vec<GpuInfo>,
}

#[derive(Debug, Clone)]
struct AmdGpuAdapter {
    info: GpuInfo,
    device_path: PathBuf,
    hwmon_path: Option<PathBuf>,
}

impl AmdGpuBackend {
    pub fn new() -> Result<Self> {
        let drm_path = Path::new("/sys/class/drm");
        let entries = fs::read_dir(drm_path).with_context(|| "failed to read /sys/class/drm")?;
        let mut adapters = Vec::new();

        for entry in entries.flatten() {
            let path = entry.path();
            let Some(card_index) = card_index(&path) else {
                continue;
            };
            let device_path = path.join("device");
            if !is_amd_device(&device_path) {
                continue;
            }

            let name = amd_device_name(card_index, &device_path);
            let uuid = read_trimmed(device_path.join("unique_id")).ok();
            let info = GpuInfo {
                id: adapters.len(),
                backend_index: card_index,
                name,
                uuid,
            };
            adapters.push(AmdGpuAdapter {
                info,
                hwmon_path: find_hwmon_path(&device_path),
                device_path,
            });
        }

        adapters.sort_by_key(|adapter| adapter.info.backend_index);
        for (id, adapter) in adapters.iter_mut().enumerate() {
            adapter.info.id = id;
        }

        let devices = adapters
            .iter()
            .map(|adapter| adapter.info.clone())
            .collect::<Vec<_>>();
        require_devices(&devices, "AMDGPU sysfs")?;

        Ok(Self { adapters, devices })
    }
}

impl GpuBackend for AmdGpuBackend {
    fn label(&self) -> &str {
        "AMDGPU sysfs"
    }

    fn devices(&self) -> &[GpuInfo] {
        &self.devices
    }

    fn sample(&mut self) -> Result<Vec<GpuSample>> {
        let at = Instant::now();

        Ok(self
            .adapters
            .iter()
            .map(|adapter| {
                let vram_used_bytes = read_u64(adapter.device_path.join("mem_info_vram_used"));
                let vram_total_bytes = read_u64(adapter.device_path.join("mem_info_vram_total"));
                let mem_util_percent = read_f64(adapter.device_path.join("mem_busy_percent"));

                GpuSample {
                    gpu_id: adapter.info.id,
                    at,
                    gpu_util_percent: read_f64(adapter.device_path.join("gpu_busy_percent")),
                    mem_util_percent,
                    vram_used_bytes,
                    vram_total_bytes,
                    power_watts: adapter.hwmon_path.as_deref().and_then(hwmon_power_watts),
                    power_limit_watts: adapter
                        .hwmon_path
                        .as_deref()
                        .and_then(hwmon_power_cap_watts),
                    temperature_celsius: adapter
                        .hwmon_path
                        .as_deref()
                        .and_then(hwmon_temperature_celsius),
                    fan_percent: adapter.hwmon_path.as_deref().and_then(hwmon_fan_percent),
                    graphics_clock_mhz: current_dpm_clock_mhz(
                        adapter.device_path.join("pp_dpm_sclk"),
                    ),
                    memory_clock_mhz: current_dpm_clock_mhz(
                        adapter.device_path.join("pp_dpm_mclk"),
                    ),
                    compute_processes: None,
                    processes: Vec::new(),
                }
            })
            .collect())
    }
}

fn card_index(path: &Path) -> Option<u32> {
    let name = path.file_name()?.to_str()?;
    let suffix = name.strip_prefix("card")?;
    (!suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit()))
        .then(|| suffix.parse().ok())?
}

fn is_amd_device(device_path: &Path) -> bool {
    matches!(
        read_trimmed(device_path.join("vendor")).as_deref(),
        Ok("0x1002")
    )
}

fn amd_device_name(card_index: u32, device_path: &Path) -> String {
    read_trimmed(device_path.join("product_name"))
        .or_else(|_| read_name_from_uevent(device_path.join("uevent")))
        .unwrap_or_else(|_| format!("AMD GPU card{card_index}"))
}

fn read_name_from_uevent(path: PathBuf) -> Result<String> {
    let uevent = fs::read_to_string(path)?;
    if let Some(driver) = uevent.lines().find_map(|line| line.strip_prefix("DRIVER="))
        && driver == "amdgpu"
    {
        return Ok("AMD GPU".to_owned());
    }

    if let Some(pci_id) = uevent.lines().find_map(|line| line.strip_prefix("PCI_ID=")) {
        return Ok(format!("AMD GPU {pci_id}"));
    }

    anyhow::bail!("uevent did not contain an AMDGPU name");
}

fn find_hwmon_path(device_path: &Path) -> Option<PathBuf> {
    let hwmon_root = device_path.join("hwmon");
    let mut candidates = fs::read_dir(hwmon_root)
        .ok()?
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .collect::<Vec<_>>();
    candidates.sort();
    candidates.into_iter().next()
}

fn hwmon_power_watts(hwmon_path: &Path) -> Option<f64> {
    read_u64(hwmon_path.join("power1_average"))
        .or_else(|| read_u64(hwmon_path.join("power1_input")))
        .map(microwatts_to_watts)
}

fn hwmon_power_cap_watts(hwmon_path: &Path) -> Option<f64> {
    read_u64(hwmon_path.join("power1_cap")).map(microwatts_to_watts)
}

fn hwmon_temperature_celsius(hwmon_path: &Path) -> Option<f64> {
    first_numbered_hwmon_file(hwmon_path, "temp", "_input")
        .and_then(read_u64)
        .map(|millidegrees| millidegrees as f64 / 1000.0)
}

fn hwmon_fan_percent(hwmon_path: &Path) -> Option<f64> {
    read_u64(hwmon_path.join("pwm1")).map(|pwm| ((pwm as f64 / 255.0) * 100.0).clamp(0.0, 100.0))
}

fn first_numbered_hwmon_file(hwmon_path: &Path, prefix: &str, suffix: &str) -> Option<PathBuf> {
    let mut candidates = fs::read_dir(hwmon_path)
        .ok()?
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| {
            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
                return false;
            };
            name.starts_with(prefix) && name.ends_with(suffix)
        })
        .collect::<Vec<_>>();
    candidates.sort();
    candidates.into_iter().next()
}

fn current_dpm_clock_mhz(path: PathBuf) -> Option<f64> {
    parse_current_dpm_clock_mhz(&read_trimmed(path).ok()?)
}

fn parse_current_dpm_clock_mhz(contents: &str) -> Option<f64> {
    contents
        .lines()
        .find(|line| line.contains('*'))
        .and_then(clock_mhz_from_dpm_line)
}

fn clock_mhz_from_dpm_line(line: &str) -> Option<f64> {
    let after_colon = line.split_once(':')?.1;
    let number = after_colon
        .chars()
        .skip_while(|ch| ch.is_whitespace())
        .take_while(|ch| ch.is_ascii_digit() || *ch == '.')
        .collect::<String>();
    number.parse().ok()
}

fn read_trimmed(path: impl AsRef<Path>) -> Result<String> {
    Ok(fs::read_to_string(path)?.trim().to_owned())
}

fn read_u64(path: impl AsRef<Path>) -> Option<u64> {
    read_trimmed(path).ok()?.parse().ok()
}

fn read_f64(path: impl AsRef<Path>) -> Option<f64> {
    read_trimmed(path).ok()?.parse().ok()
}

fn microwatts_to_watts(microwatts: u64) -> f64 {
    microwatts as f64 / 1_000_000.0
}

#[cfg(test)]
mod tests {
    use super::{card_index, parse_current_dpm_clock_mhz};
    use std::path::Path;

    #[test]
    fn card_index_only_accepts_primary_drm_cards() {
        assert_eq!(card_index(Path::new("/sys/class/drm/card0")), Some(0));
        assert_eq!(card_index(Path::new("/sys/class/drm/card12")), Some(12));
        assert_eq!(card_index(Path::new("/sys/class/drm/card0-DP-1")), None);
        assert_eq!(card_index(Path::new("/sys/class/drm/renderD128")), None);
    }

    #[test]
    fn parses_current_dpm_clock() {
        let contents = "0: 500Mhz\n1: 1200Mhz *\n";
        assert_eq!(parse_current_dpm_clock_mhz(contents), Some(1200.0));
    }
}