use std::ffi::{CStr, c_char, c_int, c_uint, c_void};
use std::ptr;
#[derive(Debug, Clone)]
pub struct GpuDevice {
pub vendor: GpuVendor,
pub name: String,
pub index: u32,
pub generation: String,
pub pci_id: String,
pub vram_mib: u64,
pub serial: Option<String>,
pub host_pci_address: String,
pub vendor_id_hex: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GpuVendor {
Nvidia,
Amd,
Intel,
}
pub fn detect_gpus() -> Vec<GpuDevice> {
let mut devices = Vec::new();
devices.extend(detect_nvidia());
devices.extend(detect_amd());
devices.extend(detect_intel());
devices
}
pub fn manufacturer_label(v: GpuVendor) -> &'static str {
match v {
GpuVendor::Nvidia => "NVIDIA",
GpuVendor::Amd => "AMD",
GpuVendor::Intel => "Intel",
}
}
pub fn has_nvidia() -> bool {
!detect_nvidia().is_empty()
}
type CUresult = c_int;
type CUdevice = c_int;
type FnCuInit = unsafe extern "C" fn(c_uint) -> CUresult;
type FnCuDeviceGetCount = unsafe extern "C" fn(*mut c_int) -> CUresult;
type FnCuDeviceGet = unsafe extern "C" fn(*mut CUdevice, c_int) -> CUresult;
type FnCuDeviceGetName = unsafe extern "C" fn(*mut c_char, c_int, CUdevice) -> CUresult;
fn detect_nvidia() -> Vec<GpuDevice> {
let lib = unsafe { libloading::Library::new("libcuda.so") }
.or_else(|_| unsafe { libloading::Library::new("libcuda.so.1") })
.or_else(|_| unsafe { libloading::Library::new("nvcuda.dll") });
let Ok(lib) = lib else { return Vec::new() };
unsafe {
let cu_init: libloading::Symbol<FnCuInit> = match lib.get(b"cuInit") {
Ok(f) => f,
Err(_) => return Vec::new(),
};
if cu_init(0) != 0 {
return Vec::new();
}
let cu_device_get_count: libloading::Symbol<FnCuDeviceGetCount> =
match lib.get(b"cuDeviceGetCount") {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let mut count: c_int = 0;
if cu_device_get_count(&mut count) != 0 || count <= 0 {
return Vec::new();
}
let cu_device_get: libloading::Symbol<FnCuDeviceGet> = match lib.get(b"cuDeviceGet") {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let cu_device_get_name: libloading::Symbol<FnCuDeviceGetName> =
match lib.get(b"cuDeviceGetName") {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let mut devices = Vec::with_capacity(count as usize);
for ordinal in 0..count {
let mut dev: CUdevice = 0;
if cu_device_get(&mut dev, ordinal) != 0 {
continue;
}
let mut name_buf = [0i8; 256];
let name = if cu_device_get_name(
name_buf.as_mut_ptr() as *mut c_char,
name_buf.len() as c_int,
dev,
) == 0
{
CStr::from_ptr(name_buf.as_ptr() as *const c_char)
.to_string_lossy()
.into_owned()
} else {
format!("NVIDIA GPU {ordinal}")
};
let nvml_lookup = nvidia_nvml_lookup(ordinal as u32);
let generation = nvidia_generation_from_name(&name);
devices.push(GpuDevice {
vendor: GpuVendor::Nvidia,
name,
index: ordinal as u32,
generation,
pci_id: nvml_lookup.pci_id,
vram_mib: nvml_lookup.vram_mib,
serial: nvml_lookup.serial,
host_pci_address: nvml_lookup.host_pci_address,
vendor_id_hex: "0x10de".into(),
});
}
let _ = ptr::null::<c_void>();
devices
}
}
fn init_nvml_with_fallback() -> Result<nvml_wrapper::Nvml, nvml_wrapper::error::NvmlError> {
match nvml_wrapper::Nvml::init() {
Ok(n) => Ok(n),
Err(_) => nvml_wrapper::Nvml::builder()
.lib_path(std::ffi::OsStr::new("libnvidia-ml.so.1"))
.init(),
}
}
#[derive(Debug, Clone, Default)]
struct NvmlLookup {
pci_id: String,
vram_mib: u64,
serial: Option<String>,
host_pci_address: String,
}
fn nvidia_nvml_lookup(ordinal: u32) -> NvmlLookup {
let nvml = match init_nvml_with_fallback() {
Ok(n) => n,
Err(_) => return NvmlLookup::default(),
};
let device = match nvml.device_by_index(ordinal) {
Ok(d) => d,
Err(_) => return NvmlLookup::default(),
};
let (pci_id, host_pci_address) = match device.pci_info() {
Ok(p) => {
let id = format!(
"0x{:04x}:0x{:04x}",
p.pci_device_id >> 16,
p.pci_device_id & 0xFFFF
);
let bus = p
.bus_id
.trim_start_matches('0')
.trim_start_matches(':')
.to_string();
let host_pci = if bus.is_empty() {
p.bus_id.clone()
} else {
bus
};
(id, host_pci)
}
Err(_) => (String::new(), String::new()),
};
let vram_mib = match device.memory_info() {
Ok(m) => m.total / 1024 / 1024,
Err(_) => 0,
};
let serial = match device.serial() {
Ok(s) => {
let trimmed = s.trim();
if trimmed.is_empty() || trimmed == "0" {
None
} else {
Some(trimmed.to_string())
}
}
Err(e) => {
tracing::debug!(error = %e, ordinal, "nvml serial unavailable");
None
}
};
NvmlLookup {
pci_id,
vram_mib,
serial,
host_pci_address,
}
}
fn nvidia_generation_from_name(name: &str) -> String {
let n = name.to_lowercase();
if n.contains("rtx 50")
|| n.contains("5050")
|| n.contains("5060")
|| n.contains("5070")
|| n.contains("5080")
|| n.contains("5090")
|| n.contains("b100")
|| n.contains("b200")
|| n.contains("gb200")
{
return "Blackwell".into();
}
if n.contains("h100") || n.contains("h200") {
return "Hopper".into();
}
if n.contains("rtx 40")
|| n.contains("4060")
|| n.contains("4070")
|| n.contains("4080")
|| n.contains("4090")
|| n.contains("ada")
|| n.contains("l4")
|| n.contains("l40")
{
return "Ada Lovelace".into();
}
if n.contains("rtx 30")
|| n.contains("3050")
|| n.contains("3060")
|| n.contains("3070")
|| n.contains("3080")
|| n.contains("3090")
|| n.contains("a10")
|| n.contains("a100")
|| n.contains("ampere")
{
return "Ampere".into();
}
if n.contains("rtx 20")
|| n.contains("2060")
|| n.contains("2070")
|| n.contains("2080")
|| n.contains(" t4")
|| n.contains("turing")
{
return "Turing".into();
}
if n.contains("gtx 10")
|| n.contains("1050")
|| n.contains("1060")
|| n.contains("1070")
|| n.contains("1080")
|| n.contains("p100")
|| n.contains("p40")
|| n.contains("pascal")
{
return "Pascal".into();
}
"Unknown".into()
}
fn detect_amd() -> Vec<GpuDevice> {
#[cfg(target_os = "linux")]
{
if let Ok(entries) = std::fs::read_dir("/sys/bus/pci/devices") {
let mut idx = 0u32;
return entries
.filter_map(|e| e.ok())
.filter_map(|entry| {
let vendor_path = entry.path().join("vendor");
let class_path = entry.path().join("class");
let vendor = std::fs::read_to_string(&vendor_path).ok()?;
let class = std::fs::read_to_string(&class_path).ok()?;
if vendor.trim() == "0x1002" && class.trim().starts_with("0x0302") {
let device_path = entry.path().join("device");
let device = std::fs::read_to_string(&device_path)
.unwrap_or_default()
.trim()
.to_string();
let after = device.trim_start_matches("0x");
let pci_id = format!("0x1002:0x{after}");
let vram_mib = read_drm_vram_mib(&entry.path());
let generation = amd_generation_from_device_id(&device);
let host_pci_address = host_pci_address_from_sysfs(&entry.path());
let serial = read_drm_serial(&entry.path());
let dev = GpuDevice {
vendor: GpuVendor::Amd,
name: format!("AMD GPU {device}"),
index: idx,
generation,
pci_id,
vram_mib,
serial,
host_pci_address,
vendor_id_hex: "0x1002".into(),
};
idx += 1;
Some(dev)
} else {
None
}
})
.collect();
}
}
Vec::new()
}
fn detect_intel() -> Vec<GpuDevice> {
#[cfg(target_os = "linux")]
{
if let Ok(entries) = std::fs::read_dir("/sys/bus/pci/devices") {
let mut idx = 0u32;
return entries
.filter_map(|e| e.ok())
.filter_map(|entry| {
let vendor_path = entry.path().join("vendor");
let class_path = entry.path().join("class");
let device_path = entry.path().join("device");
let vendor = std::fs::read_to_string(&vendor_path).ok()?;
let class = std::fs::read_to_string(&class_path).ok()?;
if vendor.trim() == "0x8086" && class.trim().starts_with("0x0300") {
let device_id_str = std::fs::read_to_string(&device_path)
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_default();
let name = intel_label_from_device_id(&device_id_str);
let pci_id = if device_id_str.starts_with("0x") {
format!("0x8086:{device_id_str}")
} else {
String::new()
};
let live_vram = read_drm_vram_mib(&entry.path());
let vram_mib = if live_vram > 0 {
live_vram
} else {
intel_vram_mib_from_device_id(&device_id_str)
.map(u64::from)
.unwrap_or(0)
};
let generation = intel_generation_from_device_id(&device_id_str);
let host_pci_address = host_pci_address_from_sysfs(&entry.path());
let serial = read_drm_serial(&entry.path());
let dev = GpuDevice {
vendor: GpuVendor::Intel,
name,
index: idx,
generation,
pci_id,
vram_mib,
serial,
host_pci_address,
vendor_id_hex: "0x8086".into(),
};
idx += 1;
Some(dev)
} else {
None
}
})
.collect();
}
}
Vec::new()
}
#[cfg(target_os = "linux")]
fn read_drm_vram_mib(device_path: &std::path::Path) -> u64 {
let direct = device_path.join("mem_info_vram_total");
if let Ok(s) = std::fs::read_to_string(&direct) {
if let Ok(bytes) = s.trim().parse::<u64>() {
return bytes / 1024 / 1024;
}
}
let drm_dir = device_path.join("drm");
if let Ok(entries) = std::fs::read_dir(&drm_dir) {
for entry in entries.flatten() {
let candidate = entry.path().join("device").join("mem_info_vram_total");
if let Ok(s) = std::fs::read_to_string(&candidate) {
if let Ok(bytes) = s.trim().parse::<u64>() {
return bytes / 1024 / 1024;
}
}
}
}
0
}
#[cfg(not(target_os = "linux"))]
fn read_drm_vram_mib(_device_path: &std::path::Path) -> u64 {
0
}
#[cfg(target_os = "linux")]
fn host_pci_address_from_sysfs(device_path: &std::path::Path) -> String {
let Some(name) = device_path.file_name().and_then(|n| n.to_str()) else {
return String::new();
};
if let Some(rest) = name.strip_prefix("0000:") {
return rest.to_string();
}
name.to_string()
}
#[cfg(not(target_os = "linux"))]
fn host_pci_address_from_sysfs(_device_path: &std::path::Path) -> String {
String::new()
}
#[cfg(target_os = "linux")]
fn read_drm_serial(device_path: &std::path::Path) -> Option<String> {
for fname in &["serial_number", "serial"] {
let path = device_path.join(fname);
if let Ok(s) = std::fs::read_to_string(&path) {
let trimmed = s.trim().to_string();
if !trimmed.is_empty() && trimmed != "0" {
return Some(trimmed);
}
}
}
None
}
#[cfg(not(target_os = "linux"))]
fn read_drm_serial(_device_path: &std::path::Path) -> Option<String> {
None
}
fn amd_generation_from_device_id(device_id: &str) -> String {
let id_u16 = device_id
.strip_prefix("0x")
.and_then(|s| u16::from_str_radix(s, 16).ok());
match id_u16 {
Some(id) if (0x7400..=0x74ff).contains(&id) => "RDNA3".into(),
Some(id) if (0x73a0..=0x73ff).contains(&id) => "RDNA2".into(),
Some(id) if (0x7300..=0x73a0).contains(&id) => "RDNA2".into(),
Some(id) if (0x7310..=0x7350).contains(&id) => "RDNA1".into(),
Some(id) if (0x6860..=0x687f).contains(&id) => "Vega".into(),
Some(id) if (0x67c0..=0x67ff).contains(&id) => "Polaris".into(),
Some(id) if (0x6980..=0x69ff).contains(&id) => "Polaris".into(),
_ => "Unknown".into(),
}
}
fn intel_generation_from_device_id(device_id: &str) -> String {
let id_u16 = device_id
.strip_prefix("0x")
.and_then(|s| u16::from_str_radix(s, 16).ok());
match id_u16 {
Some(id) if (0x5690..=0x56af).contains(&id) => "Alchemist DG2".into(),
Some(id) if (0xe200..=0xe21f).contains(&id) => "Battlemage BMG".into(),
Some(id) if (0x6420..=0x643f).contains(&id) => "Lunar Lake".into(),
Some(id) if (0x7d40..=0x7d6f).contains(&id) => "Meteor Lake".into(),
Some(id) if (0xa780..=0xa7ff).contains(&id) => "Raptor Lake".into(),
Some(id) if (0x4680..=0x46ff).contains(&id) => "Alder Lake".into(),
Some(id) if (0x9a00..=0x9aff).contains(&id) => "Tiger Lake".into(),
_ => "Unknown".into(),
}
}
fn intel_vram_mib_from_device_id(device_id: &str) -> Option<u32> {
let id_u16 = device_id
.strip_prefix("0x")
.and_then(|s| u16::from_str_radix(s, 16).ok())?;
Some(match id_u16 {
0x56a5 => 6 * 1024, 0x56a6 => 4 * 1024, 0x5693 => 4 * 1024, 0x56a0 => 8 * 1024, 0x56a1 => 8 * 1024, 0x56a2 => 8 * 1024, 0x5690 => 16 * 1024, 0x5691 => 12 * 1024, 0x5692 => 8 * 1024, 0xe20b => 12 * 1024, 0xe20c => 10 * 1024, _ => return None,
})
}
fn intel_label_from_device_id(device_id: &str) -> String {
let id_u16 = device_id
.strip_prefix("0x")
.and_then(|s| u16::from_str_radix(s, 16).ok());
match id_u16 {
Some(0x56a5) => "Intel Arc A380".into(),
Some(0x56a6) => "Intel Arc A310".into(),
Some(0x5693) => "Intel Arc A350M".into(),
Some(0x56a0) => "Intel Arc A770".into(),
Some(0x56a1) => "Intel Arc A750".into(),
Some(0x56a2) => "Intel Arc A580".into(),
Some(0x5690) => "Intel Arc A770M".into(),
Some(0x5691) => "Intel Arc A730M".into(),
Some(0x5692) => "Intel Arc A550M".into(),
Some(id) if (0x5690..=0x56af).contains(&id) => {
format!("Intel Arc Alchemist (DG2 0x{id:04x})")
}
Some(0xe20b) => "Intel Arc B580".into(),
Some(0xe20c) => "Intel Arc B570".into(),
Some(id) if (0xe200..=0xe21f).contains(&id) => {
format!("Intel Arc Battlemage (BMG 0x{id:04x})")
}
Some(id) if (0x6420..=0x643f).contains(&id) => "Intel Lunar Lake iGPU".into(),
Some(id) if (0x7d40..=0x7d6f).contains(&id) => "Intel Meteor Lake iGPU".into(),
Some(id) => format!("Intel iGPU 0x{id:04x}"),
None => "Intel GPU".into(),
}
}
#[derive(Debug, Clone, Default)]
pub struct GpuUtilization {
pub util_percent: u8,
pub encoder_percent: u8,
pub decoder_percent: u8,
pub mem_used_mib: u32,
pub mem_total_mib: u32,
pub temperature_c: Option<u8>,
}
pub struct GpuUtilizationReader {
nvml: Option<nvml_wrapper::Nvml>,
}
impl GpuUtilizationReader {
pub fn new() -> Self {
let nvml = match init_nvml_with_fallback() {
Ok(n) => Some(n),
Err(e) => {
tracing::info!(error = %e, "nvml not available; NVIDIA GPU utilisation will be 0");
None
}
};
Self { nvml }
}
pub fn read(&self, device: &GpuDevice) -> GpuUtilization {
match device.vendor {
GpuVendor::Nvidia => self.read_nvidia(device).unwrap_or_default(),
GpuVendor::Intel => self.read_intel(device).unwrap_or_default(),
GpuVendor::Amd => GpuUtilization::default(),
}
}
fn read_nvidia(&self, device: &GpuDevice) -> Option<GpuUtilization> {
let nvml = self.nvml.as_ref()?;
let dev = nvml.device_by_index(device.index).ok()?;
let util = dev.utilization_rates().ok();
let enc = dev.encoder_utilization().ok();
let dec = dev.decoder_utilization().ok();
let mem = dev.memory_info().ok();
let temp = dev
.temperature(nvml_wrapper::enum_wrappers::device::TemperatureSensor::Gpu)
.ok()
.and_then(|t| u8::try_from(t).ok());
Some(GpuUtilization {
util_percent: util.as_ref().map(|u| u.gpu.min(100) as u8).unwrap_or(0),
encoder_percent: enc
.as_ref()
.map(|e| e.utilization.min(100) as u8)
.unwrap_or(0),
decoder_percent: dec
.as_ref()
.map(|d| d.utilization.min(100) as u8)
.unwrap_or(0),
mem_used_mib: mem
.as_ref()
.map(|m| (m.used / 1024 / 1024) as u32)
.unwrap_or(0),
mem_total_mib: mem
.as_ref()
.map(|m| (m.total / 1024 / 1024) as u32)
.unwrap_or(device.vram_mib as u32),
temperature_c: temp,
})
}
#[cfg(target_os = "linux")]
fn read_intel(&self, _device: &GpuDevice) -> Option<GpuUtilization> {
let mut out = GpuUtilization::default();
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if !name_str.starts_with("card") || name_str.contains('-') {
continue;
}
let device_link = entry.path().join("device").join("vendor");
let vendor = std::fs::read_to_string(&device_link).unwrap_or_default();
if vendor.trim() != "0x8086" {
continue;
}
let cur = std::fs::read_to_string(entry.path().join("gt_cur_freq_mhz"))
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
let max = std::fs::read_to_string(entry.path().join("gt_max_freq_mhz"))
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
if let (Some(cur), Some(max)) = (cur, max) {
if max > 0 {
out.util_percent = ((cur as u64 * 100 / max as u64).min(100)) as u8;
}
}
let used =
std::fs::read_to_string(entry.path().join("device").join("mem_info_vram_used"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok());
let total = std::fs::read_to_string(
entry.path().join("device").join("mem_info_vram_total"),
)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok());
if let Some(u) = used {
out.mem_used_mib = (u / 1024 / 1024) as u32;
}
if let Some(t) = total {
out.mem_total_mib = (t / 1024 / 1024) as u32;
}
if out.mem_total_mib == 0 && _device.vram_mib > 0 {
out.mem_total_mib = _device.vram_mib as u32;
}
if out.mem_used_mib == 0 {
let bdf = read_pci_bdf_from_drm_card(&entry.path());
if let Some(bytes) = read_intel_vram_resident_bytes(bdf.as_deref()) {
out.mem_used_mib = (bytes / 1024 / 1024) as u32;
}
}
return Some(out);
}
}
if out.mem_total_mib == 0 && _device.vram_mib > 0 {
out.mem_total_mib = _device.vram_mib as u32;
}
Some(out)
}
#[cfg(not(target_os = "linux"))]
fn read_intel(&self, _device: &GpuDevice) -> Option<GpuUtilization> {
Some(GpuUtilization::default())
}
}
#[cfg(target_os = "linux")]
fn read_pci_bdf_from_drm_card(card_dir: &std::path::Path) -> Option<String> {
let target = std::fs::read_link(card_dir.join("device")).ok()?;
target
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
}
#[cfg(target_os = "linux")]
fn read_intel_vram_resident_bytes(bdf_filter: Option<&str>) -> Option<u64> {
let proc_dir = std::fs::read_dir("/proc").ok()?;
let mut total_bytes: u64 = 0;
let mut found_any_intel_client = false;
for proc_entry in proc_dir.flatten() {
let pid_name = proc_entry.file_name();
let Some(pid_str) = pid_name.to_str() else {
continue;
};
if !pid_str.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let fdinfo_dir = proc_entry.path().join("fdinfo");
let Ok(fd_entries) = std::fs::read_dir(&fdinfo_dir) else {
continue;
};
for fd_entry in fd_entries.flatten() {
let Ok(content) = std::fs::read_to_string(fd_entry.path()) else {
continue;
};
if !content.contains("drm-driver:") {
continue;
}
let is_intel = content
.lines()
.filter_map(|l| l.strip_prefix("drm-driver:"))
.any(|v| {
let v = v.trim();
v == "i915" || v == "xe"
});
if !is_intel {
continue;
}
if let Some(want_bdf) = bdf_filter {
let matches = content
.lines()
.filter_map(|l| l.strip_prefix("drm-pdev:"))
.any(|v| v.trim() == want_bdf);
if !matches {
continue;
}
}
found_any_intel_client = true;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("drm-resident-local0:") {
if let Some(bytes) = parse_drm_size(rest) {
total_bytes = total_bytes.saturating_add(bytes);
}
}
}
}
}
if found_any_intel_client {
Some(total_bytes)
} else {
None
}
}
#[cfg(target_os = "linux")]
fn parse_drm_size(s: &str) -> Option<u64> {
let trimmed = s.trim();
let mut parts = trimmed.split_whitespace();
let num: u64 = parts.next()?.parse().ok()?;
let unit = parts.next().unwrap_or("B");
let multiplier: u64 = match unit {
"B" | "" => 1,
"KiB" => 1024,
"MiB" => 1024 * 1024,
"GiB" => 1024 * 1024 * 1024,
_ => return None,
};
Some(num.saturating_mul(multiplier))
}
impl Default for GpuUtilizationReader {
fn default() -> Self {
Self::new()
}
}
pub fn supports_av1_encode(device: &GpuDevice) -> bool {
match device.vendor {
GpuVendor::Nvidia => true,
GpuVendor::Amd => true,
GpuVendor::Intel => true,
}
}