use std::path::{Path, PathBuf};
use crate::GpuInfo;
use crate::report::{GpuSurvey, NoteKind};
use crate::vendor::{GpuArch, GpuVendor};
const AMD_VENDOR_ID: u64 = 4098;
const AMD_PCI_VENDOR: &str = "0x1002";
pub(crate) fn probe(out: &mut GpuSurvey) {
probe_at(
Path::new("/sys"),
Path::new("/dev/kfd"),
rocm_runtime_root().as_deref(),
out,
);
}
fn probe_at(sys: &Path, kfd_dev: &Path, rocm: Option<&Path>, out: &mut GpuSurvey) {
let nodes_dir = sys.join("class/kfd/kfd/topology/nodes");
let gpus = read_topology(&nodes_dir);
if gpus.is_empty() {
let pci = amd_display_devices(&sys.join("bus/pci/devices"));
if !pci.is_empty() {
out.note(
GpuVendor::Amd,
NoteKind::HardwareUnusable,
format!(
"{} AMD display device(s) on PCI ({}) but no usable KFD GPU node. \
Load the `amdgpu` kernel module, or (in a container) pass the \
devices through with `--device=/dev/kfd --device=/dev/dri`.",
pci.len(),
pci.join(", "),
),
);
}
return;
}
if !kfd_dev.exists() {
out.note(
GpuVendor::Amd,
NoteKind::HardwareUnusable,
format!(
"the kernel lists {} AMD GPU(s) but {} does not exist, so no process \
can open them. In a container, add `--device=/dev/kfd \
--device=/dev/dri`; otherwise check the `amdgpu` module and the \
`render`/`video` group membership.",
gpus.len(),
kfd_dev.display(),
),
);
return;
}
let Some(rocm) = rocm else {
let archs: Vec<String> = gpus.iter().map(|g| g.arch.to_string()).collect();
out.note(
GpuVendor::Amd,
NoteKind::HardwareUnusable,
format!(
"{} AMD GPU(s) present ({}) but no ROCm runtime was found \
(`libhsa-runtime64.so` under $ROCM_PATH, $HIP_PATH, $HSA_PATH or \
/opt/rocm). They are not counted as usable devices. Install ROCm \
to train on them.",
gpus.len(),
archs.join(", "),
),
);
return;
};
let _ = rocm;
if device_rw_access(kfd_dev) == Some(false) {
let archs: Vec<String> = gpus.iter().map(|g| g.arch.to_string()).collect();
out.note(
GpuVendor::Amd,
NoteKind::HardwareUnusable,
format!(
"{} AMD GPU(s) present ({}) and {} exists, but this process cannot \
open it -- almost always missing `render`/`video` group membership. \
Fix with `sudo usermod -aG render,video $USER` then log out and back \
in (group changes do not apply to existing sessions). In a container, \
add `--group-add video --group-add render`.",
gpus.len(),
archs.join(", "),
kfd_dev.display(),
),
);
return;
}
out.devices.extend(gpus);
}
fn read_topology(nodes_dir: &Path) -> Vec<GpuInfo> {
let Ok(entries) = std::fs::read_dir(nodes_dir) else {
return Vec::new();
};
let mut dirs: Vec<(u64, PathBuf)> = entries
.flatten()
.filter_map(|e| {
let n = e.file_name().to_str()?.parse::<u64>().ok()?;
Some((n, e.path()))
})
.collect();
dirs.sort_by_key(|(n, _)| *n);
let mut out = Vec::new();
for (_, dir) in dirs {
if let Some(gpu) = read_node(&dir, out.len()) {
out.push(gpu);
}
}
out
}
fn read_node(dir: &Path, index: usize) -> Option<GpuInfo> {
let props = std::fs::read_to_string(dir.join("properties")).ok()?;
if prop(&props, "vendor_id")? != AMD_VENDOR_ID {
return None;
}
if prop(&props, "simd_count").unwrap_or(0) == 0 {
return None;
}
let arch = gfx_from_target_version(prop(&props, "gfx_target_version").unwrap_or(0))
.and_then(|token| GpuArch::parse(GpuVendor::Amd, &token))?;
let name = format!("AMD GPU {arch}");
Some(GpuInfo {
index: u8::try_from(index).ok()?,
vendor: GpuVendor::Amd,
name,
arch,
total_memory_mb: node_memory_mb(dir),
})
}
fn node_memory_mb(dir: &Path) -> u64 {
let Ok(banks) = std::fs::read_dir(dir.join("mem_banks")) else {
return 0;
};
banks
.flatten()
.filter_map(|b| std::fs::read_to_string(b.path().join("properties")).ok())
.filter_map(|text| prop(&text, "size_in_bytes"))
.max()
.unwrap_or(0)
/ (1024 * 1024)
}
fn prop(text: &str, key: &str) -> Option<u64> {
text.lines().find_map(|line| {
let mut parts = line.split_whitespace();
(parts.next()? == key).then(|| parts.next()?.parse().ok())?
})
}
fn gfx_from_target_version(v: u64) -> Option<String> {
if v == 0 {
return None;
}
let (major, minor, step) = (v / 10000, (v / 100) % 100, v % 100);
Some(format!("gfx{major}{minor}{step:x}"))
}
fn amd_display_devices(pci_dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(pci_dir) else {
return Vec::new();
};
let mut out: Vec<String> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
let vendor = std::fs::read_to_string(p.join("vendor")).ok()?;
if vendor.trim() != AMD_PCI_VENDOR {
return None;
}
let class = std::fs::read_to_string(p.join("class")).ok()?;
if !class.trim().starts_with("0x03") {
return None;
}
let device = std::fs::read_to_string(p.join("device"))
.map(|d| d.trim().to_string())
.unwrap_or_default();
Some(format!("{} {device}", e.file_name().to_string_lossy()))
})
.collect();
out.sort();
out
}
pub fn rocm_runtime_root() -> Option<PathBuf> {
rocm_runtime_root_from(&rocm_candidates())
}
pub fn rocm_runtime_lib_dir() -> Option<PathBuf> {
rocm_runtime_lib_dir_from(&rocm_candidates())
}
fn rocm_candidates() -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = ["ROCM_PATH", "HIP_PATH", "HSA_PATH"]
.iter()
.filter_map(|k| std::env::var(k).ok())
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from)
.collect();
candidates.push(PathBuf::from("/opt/rocm"));
candidates
}
fn rocm_runtime_root_from(candidates: &[PathBuf]) -> Option<PathBuf> {
rocm_runtime_lib_dir_from(candidates).and_then(|lib| lib.parent().map(Path::to_path_buf))
}
fn rocm_runtime_lib_dir_from(candidates: &[PathBuf]) -> Option<PathBuf> {
candidates.iter().find_map(|root| {
["lib", "lib64"]
.iter()
.map(|libdir| root.join(libdir))
.find(|lib| {
["libhsa-runtime64.so", "libhsa-runtime64.so.1"]
.iter()
.any(|so| lib.join(so).exists())
})
})
}
#[cfg(unix)]
fn device_rw_access(dev: &Path) -> Option<bool> {
use std::os::unix::fs::MetadataExt;
let md = std::fs::metadata(dev).ok()?;
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let euid = status_id_field(&status, "Uid:")?;
let egid = status_id_field(&status, "Gid:")?;
let groups = status_groups(&status)?;
Some(mode_grants_rw(
md.mode(),
md.uid(),
md.gid(),
euid,
egid,
&groups,
))
}
#[cfg(not(unix))]
fn device_rw_access(_dev: &Path) -> Option<bool> {
None
}
#[cfg(any(unix, test))]
fn status_id_field(status: &str, label: &str) -> Option<u32> {
status
.lines()
.find_map(|l| l.strip_prefix(label))?
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
#[cfg(any(unix, test))]
fn status_groups(status: &str) -> Option<Vec<u32>> {
Some(
status
.lines()
.find_map(|l| l.strip_prefix("Groups:"))?
.split_whitespace()
.filter_map(|g| g.parse().ok())
.collect(),
)
}
#[cfg(any(unix, test))]
fn mode_grants_rw(
mode: u32,
owner_uid: u32,
owner_gid: u32,
euid: u32,
egid: u32,
groups: &[u32],
) -> bool {
const RW: u32 = 0o6;
if euid == 0 {
return true; }
if euid == owner_uid {
return (mode >> 6) & RW == RW;
}
if egid == owner_gid || groups.contains(&owner_gid) {
return (mode >> 3) & RW == RW;
}
mode & RW == RW
}
#[cfg(test)]
mod tests {
const KFD_MODE: u32 = 0o660;
const ROOT: u32 = 0;
const RENDER_GID: u32 = 104;
#[test]
fn kfd_is_open_to_a_member_of_the_render_group() {
assert!(mode_grants_rw(
KFD_MODE,
ROOT,
RENDER_GID,
1000,
1000,
&[44, RENDER_GID]
));
}
#[test]
fn kfd_is_closed_to_a_user_outside_the_render_group() {
assert!(!mode_grants_rw(
KFD_MODE,
ROOT,
RENDER_GID,
1000,
1000,
&[44, 100]
));
}
#[test]
fn root_opens_it_regardless_of_groups() {
assert!(mode_grants_rw(KFD_MODE, ROOT, RENDER_GID, 0, 0, &[]));
}
#[test]
fn the_effective_gid_counts_as_membership() {
assert!(mode_grants_rw(
KFD_MODE,
ROOT,
RENDER_GID,
1000,
RENDER_GID,
&[]
));
}
#[test]
fn owner_bits_apply_to_the_owner_even_when_group_bits_grant_more() {
assert!(!mode_grants_rw(
0o060,
1000,
RENDER_GID,
1000,
1000,
&[RENDER_GID]
));
}
#[test]
fn world_writable_node_is_open_to_anyone() {
assert!(mode_grants_rw(0o666, ROOT, ROOT, 1000, 1000, &[]));
}
#[test]
fn read_only_group_access_is_not_enough() {
assert!(!mode_grants_rw(
0o440,
ROOT,
RENDER_GID,
1000,
1000,
&[RENDER_GID]
));
}
#[test]
fn parses_effective_ids_and_groups_from_proc_status() {
let status = "Name:\tx\nUid:\t1000\t1001\t1000\t1000\nGid:\t1000\t1002\t1000\t1000\nGroups:\t4 24 104 \n";
assert_eq!(status_id_field(status, "Uid:"), Some(1001));
assert_eq!(status_id_field(status, "Gid:"), Some(1002));
assert_eq!(status_groups(status), Some(vec![4, 24, 104]));
}
#[test]
fn an_empty_groups_line_is_empty_not_missing() {
assert_eq!(status_groups("Groups:\t\n"), Some(vec![]));
assert_eq!(status_groups("Name:\tx\n"), None);
}
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static SEQ: AtomicU64 = AtomicU64::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("flodl-hw-amd-{nanos}-{seq}"));
fs::create_dir_all(&dir).expect("scratch");
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn node(sys: &Path, n: u64, props: &str, bank_bytes: Option<u64>) {
let dir = sys.join("class/kfd/kfd/topology/nodes").join(n.to_string());
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("properties"), props).unwrap();
fs::write(dir.join("gpu_id"), "6720\n").unwrap();
fs::write(dir.join("name"), "ip discovery\n").unwrap();
if let Some(bytes) = bank_bytes {
let bank = dir.join("mem_banks/0");
fs::create_dir_all(&bank).unwrap();
fs::write(
bank.join("properties"),
format!("heap_type 1\nsize_in_bytes {bytes}\nflags 0\nwidth 128\n"),
)
.unwrap();
}
}
const CPU_NODE: &str = "cpu_cores_count 24\nsimd_count 0\ngfx_target_version 0\n\
vendor_id 0\ndevice_id 0\nlocation_id 0\n";
fn gpu_node_props(gfx_target: u64, simd: u64) -> String {
format!(
"cpu_cores_count 0\nsimd_count {simd}\nmax_waves_per_simd 32\n\
wave_front_size 32\ngfx_target_version {gfx_target}\n\
vendor_id 4098\ndevice_id 5056\nlocation_id 3328\n"
)
}
#[cfg(unix)]
fn pci_device(sys: &Path, slot: &str, vendor: &str, class: &str, device: &str) {
let d = sys.join("bus/pci/devices").join(slot);
fs::create_dir_all(&d).unwrap();
fs::write(d.join("vendor"), format!("{vendor}\n")).unwrap();
fs::write(d.join("class"), format!("{class}\n")).unwrap();
fs::write(d.join("device"), format!("{device}\n")).unwrap();
}
fn rocm_install(root: &Path, libdir: &str, soname: &str) -> PathBuf {
let lib = root.join("rocm").join(libdir);
fs::create_dir_all(&lib).unwrap();
fs::write(lib.join(soname), "").unwrap();
root.join("rocm")
}
#[test]
fn decodes_gfx_target_version() {
for (v, want) in [
(100306, "gfx1036"),
(90000, "gfx900"),
(90006, "gfx906"),
(90008, "gfx908"),
(90402, "gfx942"),
(100300, "gfx1030"),
(110000, "gfx1100"),
(110003, "gfx1103"),
(110500, "gfx1150"),
(110501, "gfx1151"),
(120001, "gfx1201"),
] {
assert_eq!(gfx_from_target_version(v).as_deref(), Some(want), "{v}");
}
}
#[test]
fn step_renders_in_hex_so_gfx90a_round_trips() {
assert_eq!(gfx_from_target_version(90010).as_deref(), Some("gfx90a"));
}
#[test]
fn absent_target_version_is_none_not_a_fabricated_arch() {
assert_eq!(gfx_from_target_version(0), None);
}
#[test]
fn prop_matches_whole_keys_only() {
let text = "cpu_cores_count 24\nsimd_count 0\nvendor_id 4098\n";
assert_eq!(prop(text, "vendor_id"), Some(4098));
assert_eq!(prop(text, "simd_count"), Some(0));
assert_eq!(prop(text, "count"), None);
assert_eq!(prop(text, "id"), None);
assert_eq!(prop(text, "missing"), None);
}
#[test]
fn prop_does_not_find_gpu_id_which_is_a_sibling_file() {
assert_eq!(prop(&gpu_node_props(100306, 4), "gpu_id"), None);
}
#[test]
fn skips_the_cpu_node_and_indexes_gpus_in_node_order() {
let s = Scratch::new();
node(s.path(), 0, CPU_NODE, None);
node(s.path(), 1, &gpu_node_props(100300, 4), Some(16106430464));
node(s.path(), 2, &gpu_node_props(110000, 8), Some(25769803776));
let gpus = read_topology(&s.path().join("class/kfd/kfd/topology/nodes"));
assert_eq!(gpus.len(), 2, "CPU node is not a GPU");
assert_eq!(gpus[0].index, 0);
assert_eq!(gpus[0].arch, GpuArch::Gfx("gfx1030".into()));
assert_eq!(gpus[0].total_memory_mb, 15360);
assert_eq!(gpus[1].index, 1);
assert_eq!(gpus[1].arch, GpuArch::Gfx("gfx1100".into()));
}
#[test]
fn node_dirs_sort_numerically_not_lexically() {
let s = Scratch::new();
node(s.path(), 0, CPU_NODE, None);
node(s.path(), 2, &gpu_node_props(100300, 4), None);
node(s.path(), 10, &gpu_node_props(110000, 4), None);
let gpus = read_topology(&s.path().join("class/kfd/kfd/topology/nodes"));
assert_eq!(
gpus.iter().map(|g| g.arch.to_string()).collect::<Vec<_>>(),
vec!["gfx1030", "gfx1100"],
);
}
#[test]
fn an_nvidia_kfd_node_is_not_an_amd_gpu() {
let s = Scratch::new();
let props = gpu_node_props(100300, 4).replace("vendor_id 4098", "vendor_id 4318");
node(s.path(), 1, &props, None);
assert!(read_topology(&s.path().join("class/kfd/kfd/topology/nodes")).is_empty());
}
#[test]
fn a_node_with_no_target_version_is_skipped_not_guessed() {
let s = Scratch::new();
node(s.path(), 1, &gpu_node_props(0, 4), None);
assert!(read_topology(&s.path().join("class/kfd/kfd/topology/nodes")).is_empty());
}
#[test]
fn missing_mem_banks_reports_zero_rather_than_guessing() {
let s = Scratch::new();
node(s.path(), 1, &gpu_node_props(100300, 4), None);
let gpus = read_topology(&s.path().join("class/kfd/kfd/topology/nodes"));
assert_eq!(gpus[0].total_memory_mb, 0);
}
#[test]
fn absent_topology_is_empty_not_an_error() {
assert!(read_topology(Path::new("/nonexistent/kfd/nodes")).is_empty());
}
#[test]
fn gpu_without_rocm_is_a_finding_not_a_device() {
let s = Scratch::new();
node(s.path(), 0, CPU_NODE, None);
node(s.path(), 1, &gpu_node_props(100306, 4), Some(16106430464));
fs::write(s.path().join("kfd-dev"), "").unwrap();
let mut out = GpuSurvey::default();
probe_at(s.path(), &s.path().join("kfd-dev"), None, &mut out);
assert!(out.devices.is_empty(), "must not report an unusable device");
assert_eq!(out.notes.len(), 1);
assert_eq!(out.notes[0].kind, NoteKind::HardwareUnusable);
assert!(out.notes[0].message.contains("gfx1036"), "names the arch");
assert!(
out.notes[0].message.contains("Install ROCm"),
"says what to do"
);
}
#[test]
fn gpu_with_rocm_is_a_device() {
let s = Scratch::new();
node(s.path(), 0, CPU_NODE, None);
node(s.path(), 1, &gpu_node_props(100300, 4), Some(17179869184));
fs::write(s.path().join("kfd-dev"), "").unwrap();
let rocm = rocm_install(s.path(), "lib", "libhsa-runtime64.so.1");
let mut out = GpuSurvey::default();
probe_at(s.path(), &s.path().join("kfd-dev"), Some(&rocm), &mut out);
assert_eq!(out.devices.len(), 1);
assert_eq!(out.devices[0].vendor, GpuVendor::Amd);
assert_eq!(out.devices[0].arch, GpuArch::Gfx("gfx1030".into()));
assert_eq!(out.devices[0].total_memory_mb, 16384);
assert!(out.notes.is_empty(), "a healthy box has nothing to report");
}
#[test]
fn sysfs_without_the_device_node_names_the_container_mistake() {
let s = Scratch::new();
node(s.path(), 1, &gpu_node_props(100300, 4), None);
let rocm = rocm_install(s.path(), "lib", "libhsa-runtime64.so");
let mut out = GpuSurvey::default();
probe_at(s.path(), &s.path().join("absent"), Some(&rocm), &mut out);
assert!(out.devices.is_empty());
assert!(
out.notes[0].message.contains("--device=/dev/kfd"),
"{:?}",
out.notes
);
}
#[cfg(unix)]
#[test]
fn pci_sharpens_the_no_kfd_case() {
let s = Scratch::new();
pci_device(s.path(), "0000:0d:00.0", "0x1002", "0x030000", "0x13c0");
let mut out = GpuSurvey::default();
probe_at(s.path(), Path::new("/nonexistent"), None, &mut out);
assert!(out.devices.is_empty());
assert_eq!(out.notes.len(), 1);
assert!(out.notes[0].message.contains("amdgpu"), "{:?}", out.notes);
assert!(
out.notes[0].message.contains("0000:0d:00.0"),
"{:?}",
out.notes
);
}
#[cfg(unix)]
#[test]
fn a_pure_nvidia_box_says_nothing_about_amd() {
let s = Scratch::new();
pci_device(s.path(), "0000:01:00.0", "0x10de", "0x030000", "0x2d04");
pci_device(s.path(), "0000:05:00.0", "0x10de", "0x030000", "0x1c03");
let mut out = GpuSurvey::default();
probe_at(s.path(), Path::new("/nonexistent"), None, &mut out);
assert!(out.devices.is_empty());
assert!(out.notes.is_empty(), "silent on a box with no AMD hardware");
}
#[cfg(unix)]
#[test]
fn a_non_display_amd_device_is_not_a_gpu() {
let s = Scratch::new();
pci_device(s.path(), "0000:00:00.0", "0x1002", "0x060000", "0x1480");
let mut out = GpuSurvey::default();
probe_at(s.path(), Path::new("/nonexistent"), None, &mut out);
assert!(out.notes.is_empty());
}
#[test]
fn runtime_root_requires_the_runtime_library() {
let s = Scratch::new();
let bare = s.path().join("bare");
fs::create_dir_all(bare.join("lib")).unwrap();
assert_eq!(rocm_runtime_root_from(&[bare]), None);
}
#[test]
fn runtime_root_probes_lib_and_lib64_and_both_sonames() {
for (libdir, soname) in [
("lib", "libhsa-runtime64.so"),
("lib", "libhsa-runtime64.so.1"),
("lib64", "libhsa-runtime64.so"),
("lib64", "libhsa-runtime64.so.1"),
] {
let s = Scratch::new();
let root = rocm_install(s.path(), libdir, soname);
assert_eq!(
rocm_runtime_root_from(std::slice::from_ref(&root)),
Some(root),
"{libdir}/{soname}"
);
}
}
#[test]
fn an_earlier_candidate_wins_over_opt_rocm() {
let s = Scratch::new();
let explicit = rocm_install(&s.path().join("explicit"), "lib", "libhsa-runtime64.so");
let stale = rocm_install(&s.path().join("stale"), "lib", "libhsa-runtime64.so");
assert_eq!(
rocm_runtime_root_from(&[explicit.clone(), stale]),
Some(explicit),
);
}
#[test]
fn no_candidates_is_none() {
assert_eq!(rocm_runtime_root_from(&[]), None);
assert_eq!(
rocm_runtime_root_from(&[PathBuf::from("/nonexistent")]),
None
);
}
#[test]
fn the_lib_dir_is_the_matched_one_not_a_composed_lib() {
let s = Scratch::new();
let root = rocm_install(s.path(), "lib64", "libhsa-runtime64.so.1");
assert_eq!(
rocm_runtime_lib_dir_from(std::slice::from_ref(&root)),
Some(root.join("lib64")),
);
}
}