use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DeviceId(pub String);
impl std::fmt::Display for DeviceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Vendor {
Nvidia,
Amd,
Intel,
Apple,
Unknown,
}
impl Vendor {
pub fn label(self) -> &'static str {
match self {
Vendor::Nvidia => "NVIDIA",
Vendor::Amd => "AMD",
Vendor::Intel => "Intel",
Vendor::Apple => "Apple",
Vendor::Unknown => "GPU",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StaticInfo {
pub id: DeviceId,
pub vendor: Vendor,
pub name: String,
pub backend: String,
pub mem_total_bytes: Option<u64>,
pub power_limit_mw: Option<u32>,
pub max_sm_clock_mhz: Option<u32>,
pub temp_slowdown_c: Option<f32>,
pub driver_version: Option<String>,
pub process_hint: Option<String>,
#[serde(default)]
pub source_caveat: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThrottleReasons {
pub thermal: bool,
pub power_cap: bool,
pub hw_slowdown: bool,
pub sync_boost: bool,
pub other: bool,
}
impl ThrottleReasons {
pub fn any(&self) -> bool {
self.thermal || self.power_cap || self.hw_slowdown || self.sync_boost || self.other
}
pub fn labels(&self) -> Vec<&'static str> {
let mut v = Vec::new();
if self.thermal {
v.push("thermal");
}
if self.power_cap {
v.push("power cap");
}
if self.hw_slowdown {
v.push("hw slowdown");
}
if self.sync_boost {
v.push("sync boost");
}
if self.other {
v.push("other");
}
v
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DynamicSample {
pub ts_ms: u64,
pub util_pct: Option<f32>,
#[serde(default)]
pub util_engine: Option<String>,
pub mem_used_bytes: Option<u64>,
pub power_mw: Option<u32>,
pub temp_c: Option<f32>,
pub fan_pct: Option<f32>,
pub sm_clock_mhz: Option<u32>,
pub mem_clock_mhz: Option<u32>,
pub encoder_pct: Option<f32>,
pub decoder_pct: Option<f32>,
#[serde(default)]
pub throttle: Option<ThrottleReasons>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProcessKind {
Compute,
Graphics,
Both,
Unknown,
}
impl ProcessKind {
pub fn label(self) -> &'static str {
match self {
ProcessKind::Compute => "C",
ProcessKind::Graphics => "G",
ProcessKind::Both => "C+G",
ProcessKind::Unknown => "?",
}
}
pub fn prose(self) -> &'static str {
match self {
ProcessKind::Compute => "compute",
ProcessKind::Graphics => "graphics",
ProcessKind::Both => "compute+graphics",
ProcessKind::Unknown => "unknown-type",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProcessSample {
pub pid: u32,
pub name: String,
pub kind: ProcessKind,
pub mem_bytes: Option<u64>,
pub util_pct: Option<f32>,
#[serde(default)]
pub cpu_pct: Option<f32>,
#[serde(default)]
pub container: Option<String>,
}
pub fn normalize_pci_id(id: &str) -> Option<String> {
let id = id.to_ascii_lowercase();
let (domain, rest) = id.split_once(':')?;
let (bus, devfn) = rest.split_once(':')?;
let (dev, func) = devfn.split_once('.')?;
let hex = |s: &str, max: usize| {
!s.is_empty() && s.len() <= max && s.bytes().all(|b| b.is_ascii_hexdigit())
};
if !hex(domain, 8) || !hex(bus, 2) || !hex(dev, 2) || !hex(func, 1) {
return None;
}
let domain = format!("{:0>4}", domain.trim_start_matches('0'));
Some(format!("{domain}:{bus:0>2}:{dev:0>2}.{func}"))
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn fmt_bytes(b: u64) -> String {
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
let b = b as f64;
if b >= GIB {
format!("{:.1} GiB", b / GIB)
} else {
format!("{:.0} MiB", b / MIB)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_pci_id_unifies_nvml_and_sysfs_forms() {
assert_eq!(
normalize_pci_id("00000000:01:00.0").as_deref(),
Some("0000:01:00.0")
);
assert_eq!(
normalize_pci_id("0000:01:00.0").as_deref(),
Some("0000:01:00.0")
);
assert_eq!(
normalize_pci_id("00000000:0A:00.0").as_deref(),
Some("0000:0a:00.0")
);
assert_eq!(
normalize_pci_id("00000001:03:00.0").as_deref(),
Some("0001:03:00.0")
);
assert_eq!(
normalize_pci_id("0001:03:00.0").as_deref(),
Some("0001:03:00.0")
);
}
#[test]
fn normalize_pci_id_rejects_non_pci_ids() {
assert_eq!(normalize_pci_id("mock:0000:01:00.0"), None);
assert_eq!(normalize_pci_id("nvml:0"), None);
assert_eq!(normalize_pci_id("wddm:10de:2684:0"), None);
assert_eq!(normalize_pci_id("apple:m2-max"), None);
assert_eq!(normalize_pci_id(""), None);
assert_eq!(normalize_pci_id("0000:01:00"), None); assert_eq!(normalize_pci_id("0000:01:00.0.1"), None); assert_eq!(normalize_pci_id("0000:01:02:00.0"), None); }
#[test]
fn throttle_none_is_a_distinct_state_from_observed_all_false() {
let unobservable: Option<ThrottleReasons> = None;
let observed_quiet = Some(ThrottleReasons::default());
assert_ne!(unobservable, observed_quiet);
assert!(!unobservable.is_some_and(|t| t.any()));
assert!(!observed_quiet.is_some_and(|t| t.any()));
}
}