//! Topology snapshot: every PCI device the kernel can see, plus its //! driver binding, DRM nodes, hwmon attachments, and a coarse kind tag. //! //! This is the unmeasured layer. `capability.rs` carries *measured* //! probe output (throughput, bandwidth, thermal envelope); `inventory` //! carries facts the kernel will tell you without running a single //! kernel on the GPU. The two are deliberately disjoint — the //! scheduler, the appraise probe runner, and the TUI audit screen all //! consume inventory first, then ask the probe layer for measurements //! on the subset they care about. //! //! Linux only. On other targets `enumerate` returns an empty //! inventory so the workspace still builds on macOS dev hosts. use std::path::PathBuf; use std::time::SystemTime; /// Snapshot of every PCI device visible to the kernel at probe time. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DeviceInventory { pub probed_at: SystemTime, pub host: HostFingerprint, pub devices: Vec, } /// Host-level identity captured alongside the device list. /// /// Not used for scoring or routing — it lets a later reader say "this /// inventory came from this box" without having to consult the report /// metadata that wraps it. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct HostFingerprint { pub hostname: String, pub kernel_release: String, pub cpu_model: String, } /// One PCI function the kernel exposes under `/sys/bus/pci/devices/`. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct EnumeratedDevice { pub bus_address: PciBusAddress, pub class: PciClass, pub ids: PciIds, /// Name of the kernel driver currently bound, if any. `None` means /// the function exists but no driver claimed it. pub driver: Option, pub numa_node: Option, pub iommu_group: Option, /// `/dev/dri/cardN` symlink target if this device is bound to a /// DRM master node. pub drm_node: Option, /// `/dev/dri/renderD12N` target if a render-only node is exposed. pub render_node: Option, /// Every `hwmon` instance attached under this PCI device's sysfs /// subtree. Most GPUs surface temperature, fan, and power sensors /// here; integrated parts and BMC framebuffers usually don't. pub hwmon: Vec, pub kind: DeviceKind, } /// PCI bus address in the canonical `DDDD:BB:DD.F` shape. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct PciBusAddress { pub segment: u16, pub bus: u8, pub device: u8, pub function: u8, } impl core::fmt::Display for PciBusAddress { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!( f, "{:04x}:{:02x}:{:02x}.{}", self.segment, self.bus, self.device, self.function ) } } /// PCI class/subclass/prog-if as raw bytes from sysfs. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct PciClass { pub base: u8, pub sub: u8, pub prog_if: u8, } /// PCI vendor / device / subsystem IDs. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct PciIds { pub vendor: u16, pub device: u16, pub subsystem_vendor: Option, pub subsystem_device: Option, } /// Coarse classification derived from PCI class bytes alone — no /// vendor table, no driver introspection. Finer distinctions /// (discrete vs integrated vs BMC framebuffer) need a real probe and /// belong in `appraise`. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum DeviceKind { /// PCI class 0x03 sub 0x00 — VGA-compatible display controller. /// Covers integrated GPUs, consumer dGPUs with a display path, /// and BMC framebuffers. VgaController, /// PCI class 0x03 sub 0x02 — display controller with no VGA /// compatibility. Typical of compute-only cards (Tesla P40, A40, /// many datacenter parts). ComputeGpu, /// PCI class 0x03 anything else. OtherDisplay, /// PCI class 0x12 — processing accelerator. Typically TPU, NPU, /// or non-GPU compute card. Accelerator, /// Anything else. Included so callers can build a complete bus /// view, not just a GPU view. Other, } impl DeviceKind { /// True for classes the scheduler and probe layers care about. #[must_use] pub const fn is_compute_candidate(self) -> bool { matches!( self, Self::ComputeGpu | Self::VgaController | Self::Accelerator ) } } /// One hwmon sensor group attached to a device. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct HwmonSensor { /// Contents of `/name` — e.g. `amdgpu`, `nvme`, `nvidia`, /// `aspeed`. Lets callers route to vendor-specific reads without /// hard-coding paths here. pub name: String, pub sysfs_path: PathBuf, } /// Options that shape what `enumerate` returns. #[derive(Clone, Copy, Debug, Default)] pub struct InventoryConfig { /// When true, every PCI function is returned. When false, the /// inventory is filtered to compute candidates (display /// controllers and accelerators). Default false. pub include_non_compute: bool, } /// Walk the system and produce a `DeviceInventory`. Linux-only; on /// other targets returns an empty inventory and an `unknown` host /// fingerprint so consumers compile and run unchanged. /// /// # Errors /// /// Returns the first I/O error encountered walking `/sys/bus/pci/`. /// Per-device parse failures are swallowed silently — a malformed /// sysfs file for one card should not erase the whole inventory. pub fn enumerate(config: InventoryConfig) -> std::io::Result { Ok(DeviceInventory { probed_at: SystemTime::now(), host: host_fingerprint(), devices: linux::enumerate_devices(config)?, }) } /// Read host-level identity. Linux fields read from `/etc/hostname`, /// `/proc/sys/kernel/osrelease`, and `/proc/cpuinfo`. Non-Linux /// targets return all-`unknown`. #[must_use] pub fn host_fingerprint() -> HostFingerprint { HostFingerprint { hostname: linux::read_hostname(), kernel_release: linux::read_kernel_release(), cpu_model: linux::read_cpu_model(), } } #[cfg(target_os = "linux")] mod linux { use std::ffi::OsString; use std::fs; use std::io; use std::os::unix::ffi::OsStringExt; use std::path::{Path, PathBuf}; use super::{ DeviceKind, EnumeratedDevice, HwmonSensor, InventoryConfig, PciBusAddress, PciClass, PciIds, }; const UNKNOWN: &str = "unknown"; pub(super) fn enumerate_devices(config: InventoryConfig) -> io::Result> { let root = Path::new("/sys/bus/pci/devices"); let entries = match fs::read_dir(root) { Ok(it) => it, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(e), }; let mut out = Vec::new(); for entry in entries.flatten() { let Some(dev) = read_device(&entry.path()) else { continue; }; if !config.include_non_compute && !dev.kind.is_compute_candidate() { continue; } out.push(dev); } out.sort_by_key(|d| { ( d.bus_address.segment, d.bus_address.bus, d.bus_address.device, d.bus_address.function, ) }); Ok(out) } fn read_device(path: &Path) -> Option { let name = path.file_name()?.to_str()?; let bus_address = parse_bus_address(name)?; let class_raw = read_hex_u32(&path.join("class"))?; let class = PciClass { base: ((class_raw >> 16) & 0xff) as u8, sub: ((class_raw >> 8) & 0xff) as u8, prog_if: (class_raw & 0xff) as u8, }; let ids = PciIds { vendor: read_hex_u16(&path.join("vendor"))?, device: read_hex_u16(&path.join("device"))?, subsystem_vendor: read_hex_u16(&path.join("subsystem_vendor")), subsystem_device: read_hex_u16(&path.join("subsystem_device")), }; let driver = read_link_basename(&path.join("driver")); let numa_node = read_trimmed(&path.join("numa_node")).and_then(|s| s.parse::().ok()); let iommu_group = read_link_basename(&path.join("iommu_group")).and_then(|s| s.parse::().ok()); let (drm_node, render_node) = read_drm_nodes(&path.join("drm")); let hwmon = read_hwmon(&path.join("hwmon")); let kind = classify(class); Some(EnumeratedDevice { bus_address, class, ids, driver, numa_node, iommu_group, drm_node, render_node, hwmon, kind, }) } fn parse_bus_address(s: &str) -> Option { // DDDD:BB:DD.F let (head, function) = s.split_once('.')?; let mut parts = head.split(':'); let segment = u16::from_str_radix(parts.next()?, 16).ok()?; let bus = u8::from_str_radix(parts.next()?, 16).ok()?; let device = u8::from_str_radix(parts.next()?, 16).ok()?; if parts.next().is_some() { return None; } let function = function.parse::().ok()?; Some(PciBusAddress { segment, bus, device, function, }) } fn classify(class: PciClass) -> DeviceKind { match (class.base, class.sub) { (0x03, 0x00) => DeviceKind::VgaController, (0x03, 0x02) => DeviceKind::ComputeGpu, (0x03, _) => DeviceKind::OtherDisplay, (0x12, _) => DeviceKind::Accelerator, _ => DeviceKind::Other, } } fn read_drm_nodes(drm_dir: &Path) -> (Option, Option) { let mut card = None; let mut render = None; let Ok(entries) = fs::read_dir(drm_dir) else { return (None, None); }; for entry in entries.flatten() { let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue; }; if name.starts_with("card") && name.len() > 4 && card.is_none() { card = Some(PathBuf::from("/dev/dri").join(&name)); } else if name.starts_with("renderD") && render.is_none() { render = Some(PathBuf::from("/dev/dri").join(&name)); } } (card, render) } fn read_hwmon(hwmon_dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(hwmon_dir) else { return Vec::new(); }; let mut out = Vec::new(); for entry in entries.flatten() { let path = entry.path(); let Some(name) = read_trimmed(&path.join("name")) else { continue; }; out.push(HwmonSensor { name, sysfs_path: path, }); } out } fn read_trimmed(path: &Path) -> Option { fs::read_to_string(path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) } fn read_hex_u32(path: &Path) -> Option { let s = read_trimmed(path)?; let stripped = s.strip_prefix("0x").unwrap_or(&s); u32::from_str_radix(stripped, 16).ok() } fn read_hex_u16(path: &Path) -> Option { let s = read_trimmed(path)?; let stripped = s.strip_prefix("0x").unwrap_or(&s); u16::from_str_radix(stripped, 16).ok() } fn read_link_basename(link: &Path) -> Option { let target = fs::read_link(link).ok()?; let bytes = target.into_os_string().into_vec(); let name = OsString::from_vec(bytes); Path::new(&name) .file_name() .and_then(|n| n.to_str()) .map(str::to_owned) } pub(super) fn read_hostname() -> String { read_trimmed(Path::new("/etc/hostname")).unwrap_or_else(|| UNKNOWN.to_owned()) } pub(super) fn read_kernel_release() -> String { read_trimmed(Path::new("/proc/sys/kernel/osrelease")).unwrap_or_else(|| UNKNOWN.to_owned()) } pub(super) fn read_cpu_model() -> String { // Matches the appraise-side reader: x86_64 surfaces "model // name"; aarch64 splits across "Model", "Hardware", "CPU part". const KEYS: &[&str] = &["model name", "Model", "Hardware", "CPU part"]; let Ok(content) = fs::read_to_string("/proc/cpuinfo") else { return UNKNOWN.to_owned(); }; for line in content.lines() { for key in KEYS { if let Some(rest) = line.strip_prefix(key) && let Some(value) = rest.split(':').nth(1) { let trimmed = value.trim(); if !trimmed.is_empty() { return trimmed.to_owned(); } } } } UNKNOWN.to_owned() } } #[cfg(not(target_os = "linux"))] mod linux { use super::{EnumeratedDevice, InventoryConfig}; const UNKNOWN: &str = "unknown"; // Signature matches the Linux variant so the public `enumerate` // surface stays portable; the Result is structurally needed even // though this body never errors. #[allow(clippy::unnecessary_wraps)] pub(super) fn enumerate_devices( _config: InventoryConfig, ) -> std::io::Result> { Ok(Vec::new()) } pub(super) fn read_hostname() -> String { UNKNOWN.to_owned() } pub(super) fn read_kernel_release() -> String { UNKNOWN.to_owned() } pub(super) fn read_cpu_model() -> String { UNKNOWN.to_owned() } } #[cfg(test)] mod tests { use super::*; #[test] fn bus_address_round_trips() { let a = PciBusAddress { segment: 0x0000, bus: 0x01, device: 0x00, function: 0, }; assert_eq!(a.to_string(), "0000:01:00.0"); let b = PciBusAddress { segment: 0x0003, bus: 0x02, device: 0x00, function: 0, }; assert_eq!(b.to_string(), "0003:02:00.0"); } #[test] fn device_kind_compute_filter() { assert!(DeviceKind::ComputeGpu.is_compute_candidate()); assert!(DeviceKind::VgaController.is_compute_candidate()); assert!(DeviceKind::Accelerator.is_compute_candidate()); assert!(!DeviceKind::OtherDisplay.is_compute_candidate()); assert!(!DeviceKind::Other.is_compute_candidate()); } #[test] fn enumerate_compiles_and_runs() { // On non-Linux this returns empty; on Linux it walks /sys. // Either is acceptable — the test just exercises the surface. let inv = enumerate(InventoryConfig::default()).expect("enumerate should not error"); assert!(!inv.host.hostname.is_empty()); } }