use bincode::{Decode, Encode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Encode, Decode)]
pub enum GpuVendor {
Nvidia,
Amd,
Intel,
Apple,
Unknown,
}
impl GpuVendor {
pub fn label(self) -> &'static str {
match self {
GpuVendor::Nvidia => "nvidia",
GpuVendor::Amd => "amd",
GpuVendor::Intel => "intel",
GpuVendor::Apple => "apple",
GpuVendor::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Encode, Decode)]
pub enum GpuBackend {
Nvml,
AmdSysfs,
AppleIoReport,
}
impl GpuBackend {
pub fn label(self) -> &'static str {
match self {
GpuBackend::Nvml => "nvml",
GpuBackend::AmdSysfs => "amdgpu-sysfs",
GpuBackend::AppleIoReport => "ioreport",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Encode, Decode)]
pub enum GpuProcessKind {
Compute,
Graphics,
Both,
Unknown,
}
impl GpuProcessKind {
pub fn label(self) -> &'static str {
match self {
GpuProcessKind::Compute => "compute",
GpuProcessKind::Graphics => "graphics",
GpuProcessKind::Both => "both",
GpuProcessKind::Unknown => "unknown",
}
}
pub fn merge(self, other: Self) -> Self {
match (self, other) {
(a, b) if a == b => a,
(GpuProcessKind::Unknown, b) => b,
(a, GpuProcessKind::Unknown) => a,
_ => GpuProcessKind::Both,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode)]
pub struct GpuDeviceSnapshot {
pub index: u32,
pub vendor: GpuVendor,
pub backend: GpuBackend,
pub name: String,
pub bus_id: String,
pub driver_version: Option<String>,
pub utilization_pct: Option<f32>,
pub mem_utilization_pct: Option<f32>,
pub mem_used_bytes: Option<u64>,
pub mem_total_bytes: Option<u64>,
pub temperature_c: Option<f32>,
pub power_watts: Option<f32>,
pub power_limit_watts: Option<f32>,
pub graphics_clock_mhz: Option<u32>,
pub memory_clock_mhz: Option<u32>,
pub fan_pct: Option<f32>,
pub encoder_pct: Option<f32>,
pub decoder_pct: Option<f32>,
pub supports_process_stats: bool,
}
impl GpuDeviceSnapshot {
pub fn mem_pct(&self) -> Option<f32> {
let (used, total) = (self.mem_used_bytes?, self.mem_total_bytes?);
if total == 0 {
return None;
}
Some((used as f64 / total as f64 * 100.0) as f32)
}
pub fn power_pct(&self) -> Option<f32> {
let (used, limit) = (self.power_watts?, self.power_limit_watts?);
if limit <= 0.0 {
return None;
}
Some(used / limit * 100.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode)]
pub struct GpuProcessSnapshot {
pub pid: u32,
pub device_index: u32,
pub name: String,
pub kind: GpuProcessKind,
pub mem_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode)]
pub struct GpusSnapshot {
pub backends: Vec<GpuBackend>,
pub available: bool,
pub devices: Vec<GpuDeviceSnapshot>,
pub processes: Vec<GpuProcessSnapshot>,
pub detail: String,
}
impl GpusSnapshot {
pub fn unavailable() -> Self {
Self {
backends: Vec::new(),
available: false,
devices: Vec::new(),
processes: Vec::new(),
detail: String::new(),
}
}
pub fn unavailable_with(detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
..Self::unavailable()
}
}
pub fn any_process_stats(&self) -> bool {
self.devices.iter().any(|d| d.supports_process_stats)
}
pub fn resolve_process_names<F>(&mut self, lookup: F)
where
F: Fn(u32) -> Option<String>,
{
for process in &mut self.processes {
if process.name.is_empty()
&& let Some(name) = lookup(process.pid)
{
process.name = name;
}
}
}
pub fn total_memory(&self) -> Option<(u64, u64)> {
let mut used = 0u64;
let mut total = 0u64;
let mut seen = false;
for d in &self.devices {
if let (Some(u), Some(t)) = (d.mem_used_bytes, d.mem_total_bytes) {
used = used.saturating_add(u);
total = total.saturating_add(t);
seen = true;
}
}
seen.then_some((used, total))
}
}
#[cfg(test)]
mod tests {
use super::*;
use bincode::config;
fn sample_device() -> GpuDeviceSnapshot {
GpuDeviceSnapshot {
index: 0,
vendor: GpuVendor::Nvidia,
backend: GpuBackend::Nvml,
name: "NVIDIA GeForce RTX 4090".into(),
bus_id: "0000:01:00.0".into(),
driver_version: Some("560.35.03".into()),
utilization_pct: Some(42.0),
mem_utilization_pct: Some(18.0),
mem_used_bytes: Some(6 * 1024 * 1024 * 1024),
mem_total_bytes: Some(24 * 1024 * 1024 * 1024),
temperature_c: Some(64.0),
power_watts: Some(210.5),
power_limit_watts: Some(450.0),
graphics_clock_mhz: Some(2520),
memory_clock_mhz: Some(10501),
fan_pct: Some(38.0),
encoder_pct: Some(0.0),
decoder_pct: Some(12.0),
supports_process_stats: true,
}
}
fn sample_process() -> GpuProcessSnapshot {
GpuProcessSnapshot {
pid: 4242,
device_index: 0,
name: "ollama".into(),
kind: GpuProcessKind::Compute,
mem_bytes: Some(3 * 1024 * 1024 * 1024),
}
}
#[test]
fn gpu_device_snapshot_derive_round_trip() {
let original = sample_device();
let cfg = config::standard();
let bytes = bincode::encode_to_vec(&original, cfg).expect("encode");
let (decoded, _len): (GpuDeviceSnapshot, usize) =
bincode::decode_from_slice(&bytes, cfg).expect("decode");
assert_eq!(original, decoded);
}
#[test]
fn gpu_process_snapshot_derive_round_trip() {
let original = sample_process();
let cfg = config::standard();
let bytes = bincode::encode_to_vec(&original, cfg).expect("encode");
let (decoded, _len): (GpuProcessSnapshot, usize) =
bincode::decode_from_slice(&bytes, cfg).expect("decode");
assert_eq!(original, decoded);
}
#[test]
fn gpus_snapshot_derive_round_trip() {
let original = GpusSnapshot {
backends: vec![GpuBackend::Nvml, GpuBackend::AmdSysfs],
available: true,
devices: vec![sample_device()],
processes: vec![sample_process()],
detail: String::new(),
};
let cfg = config::standard();
let bytes = bincode::encode_to_vec(&original, cfg).expect("encode");
let (decoded, _len): (GpusSnapshot, usize) =
bincode::decode_from_slice(&bytes, cfg).expect("decode");
assert_eq!(original, decoded);
}
#[test]
fn none_metrics_survive_the_wire() {
let mut device = sample_device();
device.utilization_pct = None;
device.temperature_c = None;
device.power_watts = None;
device.mem_used_bytes = None;
let cfg = config::standard();
let bytes = bincode::encode_to_vec(&device, cfg).expect("encode");
let (decoded, _): (GpuDeviceSnapshot, usize) =
bincode::decode_from_slice(&bytes, cfg).expect("decode");
assert_eq!(decoded.utilization_pct, None);
assert_eq!(decoded.temperature_c, None);
assert_eq!(decoded.power_watts, None);
assert_eq!(decoded.mem_used_bytes, None);
}
#[test]
fn vendor_and_backend_round_trip() {
let cfg = config::standard();
for vendor in [
GpuVendor::Nvidia,
GpuVendor::Amd,
GpuVendor::Intel,
GpuVendor::Apple,
GpuVendor::Unknown,
] {
let bytes = bincode::encode_to_vec(vendor, cfg).expect("encode");
let (decoded, _): (GpuVendor, usize) =
bincode::decode_from_slice(&bytes, cfg).expect("decode");
assert_eq!(vendor, decoded);
}
for backend in [
GpuBackend::Nvml,
GpuBackend::AmdSysfs,
GpuBackend::AppleIoReport,
] {
let bytes = bincode::encode_to_vec(backend, cfg).expect("encode");
let (decoded, _): (GpuBackend, usize) =
bincode::decode_from_slice(&bytes, cfg).expect("decode");
assert_eq!(backend, decoded);
}
}
#[test]
fn vendor_labels_are_exhaustive() {
for vendor in [
GpuVendor::Nvidia,
GpuVendor::Amd,
GpuVendor::Intel,
GpuVendor::Apple,
GpuVendor::Unknown,
] {
let label: &'static str = match vendor {
GpuVendor::Nvidia => "nvidia",
GpuVendor::Amd => "amd",
GpuVendor::Intel => "intel",
GpuVendor::Apple => "apple",
GpuVendor::Unknown => "unknown",
};
assert_eq!(vendor.label(), label);
}
}
#[test]
fn backend_labels_are_exhaustive() {
for backend in [
GpuBackend::Nvml,
GpuBackend::AmdSysfs,
GpuBackend::AppleIoReport,
] {
assert!(!backend.label().is_empty());
}
}
#[test]
fn process_kind_merge_promotes_to_both() {
assert_eq!(
GpuProcessKind::Compute.merge(GpuProcessKind::Graphics),
GpuProcessKind::Both
);
assert_eq!(
GpuProcessKind::Graphics.merge(GpuProcessKind::Compute),
GpuProcessKind::Both
);
}
#[test]
fn process_kind_merge_is_idempotent() {
for kind in [
GpuProcessKind::Compute,
GpuProcessKind::Graphics,
GpuProcessKind::Both,
GpuProcessKind::Unknown,
] {
assert_eq!(kind.merge(kind), kind);
}
}
#[test]
fn process_kind_merge_never_downgrades_to_unknown() {
for kind in [
GpuProcessKind::Compute,
GpuProcessKind::Graphics,
GpuProcessKind::Both,
] {
assert_eq!(kind.merge(GpuProcessKind::Unknown), kind);
assert_eq!(GpuProcessKind::Unknown.merge(kind), kind);
}
}
#[test]
fn process_kind_merge_keeps_both_absorbing() {
assert_eq!(
GpuProcessKind::Both.merge(GpuProcessKind::Compute),
GpuProcessKind::Both
);
assert_eq!(
GpuProcessKind::Compute.merge(GpuProcessKind::Both),
GpuProcessKind::Both
);
}
#[test]
fn mem_pct_computes_occupancy() {
let device = sample_device();
let pct = device.mem_pct().expect("both sides known");
assert!((pct - 25.0).abs() < 0.01, "expected 25%, got {pct}");
}
#[test]
fn mem_pct_is_none_when_either_side_unknown() {
let mut device = sample_device();
device.mem_used_bytes = None;
assert_eq!(device.mem_pct(), None);
let mut device = sample_device();
device.mem_total_bytes = None;
assert_eq!(device.mem_pct(), None);
}
#[test]
fn mem_pct_is_none_on_zero_total() {
let mut device = sample_device();
device.mem_total_bytes = Some(0);
assert_eq!(device.mem_pct(), None);
}
#[test]
fn power_pct_computes_headroom() {
let device = sample_device();
let pct = device.power_pct().expect("both sides known");
assert!((pct - 46.777).abs() < 0.01, "expected ~46.8%, got {pct}");
}
#[test]
fn power_pct_is_none_on_zero_limit() {
let mut device = sample_device();
device.power_limit_watts = Some(0.0);
assert_eq!(device.power_pct(), None);
}
#[test]
fn gpus_snapshot_unavailable_is_empty() {
let s = GpusSnapshot::unavailable();
assert!(!s.available);
assert!(s.devices.is_empty());
assert!(s.processes.is_empty());
assert!(s.backends.is_empty());
assert!(s.detail.is_empty());
}
#[test]
fn gpus_snapshot_unavailable_with_carries_detail() {
let s = GpusSnapshot::unavailable_with("no NVIDIA driver loaded");
assert!(!s.available);
assert_eq!(s.detail, "no NVIDIA driver loaded");
}
#[test]
fn any_process_stats_reflects_devices() {
let mut snap = GpusSnapshot::unavailable();
assert!(!snap.any_process_stats());
let mut amd = sample_device();
amd.supports_process_stats = false;
snap.devices.push(amd);
assert!(!snap.any_process_stats());
snap.devices.push(sample_device());
assert!(snap.any_process_stats());
}
#[test]
fn total_memory_sums_reporting_devices() {
let mut snap = GpusSnapshot::unavailable();
snap.devices.push(sample_device());
snap.devices.push(sample_device());
let (used, total) = snap.total_memory().expect("two reporting devices");
assert_eq!(used, 12 * 1024 * 1024 * 1024);
assert_eq!(total, 48 * 1024 * 1024 * 1024);
}
#[test]
fn total_memory_is_none_when_nobody_reports() {
let mut snap = GpusSnapshot::unavailable();
let mut device = sample_device();
device.mem_used_bytes = None;
device.mem_total_bytes = None;
snap.devices.push(device);
assert_eq!(snap.total_memory(), None);
}
#[test]
fn total_memory_skips_partial_reporters() {
let mut snap = GpusSnapshot::unavailable();
let mut partial = sample_device();
partial.mem_used_bytes = None;
snap.devices.push(partial);
snap.devices.push(sample_device());
let (used, total) = snap.total_memory().expect("one full reporter");
assert_eq!(used, 6 * 1024 * 1024 * 1024);
assert_eq!(total, 24 * 1024 * 1024 * 1024);
}
#[test]
fn resolve_process_names_fills_from_the_lookup() {
let mut snap = GpusSnapshot::unavailable();
snap.processes.push(GpuProcessSnapshot {
pid: 7,
device_index: 0,
name: String::new(),
kind: GpuProcessKind::Compute,
mem_bytes: None,
});
snap.resolve_process_names(|pid| (pid == 7).then(|| "python3".to_string()));
assert_eq!(snap.processes[0].name, "python3");
}
#[test]
fn resolve_process_names_leaves_exited_pids_empty() {
let mut snap = GpusSnapshot::unavailable();
snap.processes.push(GpuProcessSnapshot {
pid: 999_999,
device_index: 0,
name: String::new(),
kind: GpuProcessKind::Graphics,
mem_bytes: Some(1024),
});
snap.resolve_process_names(|_| None);
assert!(snap.processes[0].name.is_empty());
assert_eq!(snap.processes[0].mem_bytes, Some(1024));
}
#[test]
fn resolve_process_names_does_not_overwrite_a_known_name() {
let mut snap = GpusSnapshot::unavailable();
snap.processes.push(GpuProcessSnapshot {
pid: 7,
device_index: 0,
name: "from-backend".into(),
kind: GpuProcessKind::Compute,
mem_bytes: None,
});
snap.resolve_process_names(|_| Some("from-host-table".to_string()));
assert_eq!(snap.processes[0].name, "from-backend");
}
#[test]
fn snapshots_are_send_clone() {
fn assert_send_clone<T: Send + Clone>() {}
assert_send_clone::<GpuDeviceSnapshot>();
assert_send_clone::<GpuProcessSnapshot>();
assert_send_clone::<GpusSnapshot>();
}
}