use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::os::unix::fs::FileTypeExt;
use std::path::{Path, PathBuf};
use super::{Accelerators, Mib};
#[cfg(target_os = "linux")]
use super::{PROBE_TIMEOUT, probe};
use crate::ByteSize;
#[cfg(target_os = "linux")]
const NVIDIA_SMI_PROGRAM: &str = "nvidia-smi";
#[cfg(target_os = "linux")]
const NVIDIA_COMPUTE_ARGS: &[&str] = &[
"--query-gpu=compute_cap,compute_mode",
"--format=csv,noheader,nounits",
];
#[cfg(target_os = "linux")]
const NVIDIA_MEMORY_ARGS: &[&str] = &[
"--query-gpu=pci.bus_id,memory.total",
"--format=csv,noheader,nounits",
];
#[cfg(target_os = "linux")]
const DRM_DEVICE_PATH: &str = "/dev/dri";
#[cfg(target_os = "linux")]
const DRM_SYSFS_PATH: &str = "/sys/class/drm";
const NVIDIA_COMPUTE_MODES: [&str; 3] = ["Default", "Exclusive_Process", "Exclusive_Thread"];
type PciAddress = (u32, u8, u8, u8);
#[cfg(target_os = "linux")]
pub(super) fn detect() -> Accelerators {
let output = probe::output(NVIDIA_SMI_PROGRAM, NVIDIA_COMPUTE_ARGS, PROBE_TIMEOUT).ok();
let memory_output = probe::output(NVIDIA_SMI_PROGRAM, NVIDIA_MEMORY_ARGS, PROBE_TIMEOUT).ok();
let mut accelerators = detect_graphics(
Path::new(DRM_DEVICE_PATH),
Path::new(DRM_SYSFS_PATH),
memory_output.as_deref(),
);
accelerators.cuda = output.as_deref().is_some_and(nvidia_compute_available);
accelerators
}
fn detect_graphics(directory: &Path, sysfs: &Path, nvidia_output: Option<&[u8]>) -> Accelerators {
inspect(directory, sysfs, nvidia_output, |path| {
OpenOptions::new()
.read(true)
.write(true)
.open(path)
.and_then(|file| file.metadata())
.is_ok_and(|metadata| metadata.file_type().is_char_device())
})
}
fn inspect(
directory: &Path,
sysfs: &Path,
nvidia_output: Option<&[u8]>,
accessible: impl Fn(&Path) -> bool,
) -> Accelerators {
let nodes = render_devices(directory, accessible);
let nvidia_memory = nvidia_memory(nvidia_output.unwrap_or_default());
let dedicated_memory = nodes
.iter()
.filter_map(|node| node_memory(sysfs, node, &nvidia_memory))
.min();
Accelerators {
gpu: !nodes.is_empty(),
dedicated_memory,
..Accelerators::default()
}
}
fn node_memory(
sysfs: &Path,
node: &Path,
nvidia_memory: &BTreeMap<PciAddress, Mib>,
) -> Option<Mib> {
let device = sysfs.join(node.file_name()?).join("device");
sysfs_memory(&device).or_else(|| {
let pci_device = device.canonicalize().ok()?;
let address = pci_address(pci_device.file_name()?.to_str()?)?;
nvidia_memory.get(&address).copied()
})
}
fn render_devices(directory: &Path, accessible: impl Fn(&Path) -> bool) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(directory) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_str()
.and_then(|name| name.strip_prefix("renderD"))
.is_some_and(|number| {
!number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit())
})
&& accessible(&entry.path())
})
.map(|entry| entry.path())
.collect()
}
fn sysfs_memory(device: &Path) -> Option<Mib> {
let bytes = fs::read_to_string(device.join("mem_info_vram_total"))
.ok()?
.trim()
.parse::<u64>()
.ok()?;
let memory = Mib::try_from(ByteSize::from_bytes(bytes)).ok()?;
(memory > Mib::new(0)).then_some(memory)
}
fn nvidia_compute_available(output: &[u8]) -> bool {
let Ok(output) = std::str::from_utf8(output) else {
return false;
};
output.lines().any(nvidia_device_compute_available)
}
fn nvidia_device_compute_available(line: &str) -> bool {
let Some((capability, mode)) = line.split_once(',') else {
return false;
};
let Some((major, minor)) = capability.trim().split_once('.') else {
return false;
};
major.parse::<u32>().is_ok_and(|major| major > 0)
&& minor.parse::<u32>().is_ok()
&& NVIDIA_COMPUTE_MODES.contains(&mode.trim())
}
fn nvidia_memory(output: &[u8]) -> BTreeMap<PciAddress, Mib> {
let Ok(output) = std::str::from_utf8(output) else {
return BTreeMap::new();
};
let mut devices = BTreeMap::new();
for (address, memory) in output.lines().filter_map(nvidia_memory_row) {
devices
.entry(address)
.and_modify(|existing: &mut Mib| *existing = (*existing).min(memory))
.or_insert(memory);
}
devices
}
fn nvidia_memory_row(line: &str) -> Option<(PciAddress, Mib)> {
let (address, memory) = line.split_once(',')?;
let address = pci_address(address.trim())?;
let memory = memory.trim().parse::<u32>().ok()?;
(memory > 0).then_some((address, Mib::new(memory)))
}
fn pci_address(value: &str) -> Option<PciAddress> {
let mut parts = value.split(':');
let domain = u32::from_str_radix(parts.next()?, 16).ok()?;
let bus = u8::from_str_radix(parts.next()?, 16).ok()?;
let (device, function) = parts.next()?.split_once('.')?;
let device = u8::from_str_radix(device, 16).ok()?;
let function = u8::from_str_radix(function, 16).ok()?;
if parts.next().is_some() || device > 31 || function > 7 {
return None;
}
Some((domain, bus, device, function))
}
#[cfg(test)]
mod tests {
use super::{
ByteSize, Mib, detect_graphics, inspect, nvidia_compute_available, nvidia_memory,
pci_address, render_devices, sysfs_memory,
};
use std::fs;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
fn add_device(root: &Path, node: &str, pci: &str, memory_bytes: Option<&str>) {
let dri = root.join("dri");
fs::create_dir_all(&dri).unwrap();
fs::write(dri.join(node), "").unwrap();
let entry = root.join("sysfs").join(node);
fs::create_dir_all(&entry).unwrap();
let device = root.join("pci").join(pci);
fs::create_dir_all(&device).unwrap();
symlink(&device, entry.join("device")).unwrap();
if let Some(bytes) = memory_bytes {
fs::write(device.join("mem_info_vram_total"), bytes).unwrap();
}
}
#[test]
fn accepts_only_accessible_render_nodes() {
let directory = tempfile::tempdir().unwrap();
for name in ["card0", "renderD", "renderDnot-a-device", "renderD128"] {
fs::write(directory.path().join(name), "").unwrap();
}
assert_eq!(
render_devices(directory.path(), |path| path.file_name().unwrap()
== "renderD128"),
vec![directory.path().join("renderD128")],
);
assert_eq!(
render_devices(directory.path(), |_| false),
Vec::<PathBuf>::new()
);
fs::remove_file(directory.path().join("renderD128")).unwrap();
assert_eq!(
render_devices(directory.path(), |_| true),
Vec::<PathBuf>::new()
);
}
#[test]
fn missing_paths_and_regular_files_do_not_count_as_gpus() {
let directory = tempfile::tempdir().unwrap();
let sysfs = directory.path().join("sysfs");
let missing = detect_graphics(&directory.path().join("missing"), &sysfs, None);
assert!(!missing.gpu);
assert_eq!(missing.dedicated_memory, None);
fs::write(directory.path().join("renderD128"), "").unwrap();
assert!(!detect_graphics(directory.path(), &sysfs, None).gpu);
}
#[test]
fn reads_sysfs_bytes_and_uses_smallest_accessible_capacity_without_summing() {
let root = tempfile::tempdir().unwrap();
add_device(
root.path(),
"renderD128",
"0000:01:00.0",
Some("8589934592\n"),
);
add_device(
root.path(),
"renderD129",
"0000:02:00.0",
Some("4294967296\n"),
);
add_device(
root.path(),
"renderD130",
"0000:03:00.0",
Some("1073741824\n"),
);
let detected = inspect(
&root.path().join("dri"),
&root.path().join("sysfs"),
None,
|path| path.file_name().unwrap() != "renderD130",
);
assert!(detected.gpu);
assert_eq!(detected.dedicated_memory, Some(Mib::new(4096)));
}
#[test]
fn matches_nvidia_memory_to_render_device_pci_address_including_domain() {
let root = tempfile::tempdir().unwrap();
add_device(root.path(), "renderD128", "0000:0a:00.0", None);
add_device(root.path(), "renderD129", "0000:0b:00.0", None);
let report = b"00000000:0A:00.0, 8192\n00000000:0B:00.0, 4096\n\
00000001:0A:00.0, 512\n00000000:FF:00.0, 1024\n";
let detected = inspect(
&root.path().join("dri"),
&root.path().join("sysfs"),
Some(report),
|_| true,
);
assert!(detected.gpu);
assert_eq!(detected.dedicated_memory, Some(Mib::new(4096)));
}
#[test]
fn ignores_nvidia_memory_when_there_is_no_accessible_graphics_device() {
let root = tempfile::tempdir().unwrap();
add_device(root.path(), "renderD128", "0000:01:00.0", None);
let detected = inspect(
&root.path().join("dri"),
&root.path().join("sysfs"),
Some(b"00000000:01:00.0, 8192\n"),
|_| false,
);
assert!(!detected.gpu);
assert_eq!(detected.dedicated_memory, None);
}
#[test]
fn missing_or_invalid_memory_preserves_graphics_detection() {
for memory in [
None,
Some("0"),
Some("garbage"),
Some("-1"),
Some("1023"),
Some("18446744073709551615"),
] {
let root = tempfile::tempdir().unwrap();
add_device(root.path(), "renderD128", "0000:01:00.0", memory);
let detected = inspect(
&root.path().join("dri"),
&root.path().join("sysfs"),
Some(b"00000000:01:00.0, [N/A]\n"),
|_| true,
);
assert!(detected.gpu);
assert_eq!(detected.dedicated_memory, None);
}
}
#[test]
fn sysfs_memory_rounds_down_and_rejects_values_outside_mib_range() {
let device = tempfile::tempdir().unwrap();
let one_mib = ByteSize::checked_from_mib(1).unwrap().as_bytes();
let max = ByteSize::checked_from_mib(u64::from(u32::MAX))
.unwrap()
.as_bytes();
for (bytes, expected) in [
(0, None),
(one_mib - 1, None),
(one_mib, Some(1)),
(one_mib + 1, Some(1)),
(max, Some(u32::MAX)),
(max + one_mib - 1, Some(u32::MAX)),
(max + one_mib, None),
(u64::MAX, None),
] {
fs::write(device.path().join("mem_info_vram_total"), bytes.to_string()).unwrap();
assert_eq!(sysfs_memory(device.path()), expected.map(Mib::new));
}
}
#[test]
fn oversized_sysfs_memory_falls_back_to_nvidia_memory() {
let root = tempfile::tempdir().unwrap();
add_device(
root.path(),
"renderD128",
"0000:01:00.0",
Some("18446744073709551615"),
);
let detected = inspect(
&root.path().join("dri"),
&root.path().join("sysfs"),
Some(b"00000000:01:00.0, 8192\n"),
|_| true,
);
assert!(detected.gpu);
assert_eq!(detected.dedicated_memory, Some(Mib::new(8192)));
}
#[test]
fn detects_nvidia_compute_without_requiring_a_toolkit() {
for report in [
b"8.6, Default\n".as_slice(),
b"9.0, Exclusive_Process\r\n",
b"3.5, Exclusive_Thread\n",
b"[N/A], Prohibited\n8.0, Default\n",
b" 12.0 , Default \n",
] {
assert!(nvidia_compute_available(report));
}
}
#[test]
fn no_device_bad_output_or_prohibited_compute_does_not_enable_cuda() {
for report in [
b"".as_slice(),
b"No devices were found",
b"NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver.",
b"8.6, Prohibited\n",
b"[N/A], Default\n",
b"0.0, Default\n",
b"8, Default\n",
b".6, Default\n",
b"8., Default\n",
b"-8.6, Default\n",
b"bad.6, Default\n",
b"8.bad, Default\n",
b"8.6.1, Default\n",
b"4294967296.0, Default\n",
b"8.4294967296, Default\n",
b"8.6,\n",
b"8.6, Unknown\n",
b"8.6, Default, unexpected\n",
b"nvcc: release 12.4",
b"\xff",
] {
assert!(!nvidia_compute_available(report));
}
}
#[test]
fn nvidia_memory_accepts_mib_maximum_and_rejects_larger_values() {
let memory = nvidia_memory(
b"0000:01:00.0, 4294967295\n0000:02:00.0, 4294967296\n\
0000:03:00.0, 18446744073709551615\n",
);
assert_eq!(memory.len(), 1);
assert_eq!(
memory[&pci_address("0000:01:00.0").unwrap()],
Mib::new(u32::MAX)
);
}
#[test]
fn parses_nvidia_mib_and_ignores_malformed_values() {
let memory = nvidia_memory(
b"00000000:01:00.0, 8192\r\n00000000:01:00.0, 4096\n\
0000:02:00.0, 0\n0000:03:00.0, -1\n0000:04:00.0, [N/A]\n\
0000:05:00.0, 4096 MiB\n0000:06:00.0, 18446744073709551616\n\
bad address, 8192\n0000:07:00.0, 4096, extra\n",
);
assert_eq!(memory.len(), 1);
assert_eq!(
memory[&pci_address("0000:01:00.0").unwrap()],
Mib::new(4096)
);
assert!(nvidia_memory(b"\xff").is_empty());
assert!(nvidia_memory(b"No devices were found").is_empty());
}
#[test]
fn rejects_invalid_pci_addresses() {
for address in [
"",
"01:00.0",
"0000:gg:00.0",
"0000:01:20.0",
"0000:01:00.8",
"0000:01:00.0:extra",
] {
assert!(pci_address(address).is_none(), "accepted {address}");
}
}
}