Skip to main content

max / everycycle

15.1 KB · 453 lines History Blame Raw
1 //! Topology snapshot: every PCI device the kernel can see, plus its
2 //! driver binding, DRM nodes, hwmon attachments, and a coarse kind tag.
3 //!
4 //! This is the unmeasured layer. `capability.rs` carries *measured*
5 //! probe output (throughput, bandwidth, thermal envelope); `inventory`
6 //! carries facts the kernel will tell you without running a single
7 //! kernel on the GPU. The two are deliberately disjoint — the
8 //! scheduler, the appraise probe runner, and the TUI audit screen all
9 //! consume inventory first, then ask the probe layer for measurements
10 //! on the subset they care about.
11 //!
12 //! Linux only. On other targets `enumerate` returns an empty
13 //! inventory so the workspace still builds on macOS dev hosts.
14
15 use std::path::PathBuf;
16 use std::time::SystemTime;
17
18 /// Snapshot of every PCI device visible to the kernel at probe time.
19 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
20 pub struct DeviceInventory {
21 pub probed_at: SystemTime,
22 pub host: HostFingerprint,
23 pub devices: Vec<EnumeratedDevice>,
24 }
25
26 /// Host-level identity captured alongside the device list.
27 ///
28 /// Not used for scoring or routing — it lets a later reader say "this
29 /// inventory came from this box" without having to consult the report
30 /// metadata that wraps it.
31 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
32 pub struct HostFingerprint {
33 pub hostname: String,
34 pub kernel_release: String,
35 pub cpu_model: String,
36 }
37
38 /// One PCI function the kernel exposes under `/sys/bus/pci/devices/`.
39 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
40 pub struct EnumeratedDevice {
41 pub bus_address: PciBusAddress,
42 pub class: PciClass,
43 pub ids: PciIds,
44 /// Name of the kernel driver currently bound, if any. `None` means
45 /// the function exists but no driver claimed it.
46 pub driver: Option<String>,
47 pub numa_node: Option<i16>,
48 pub iommu_group: Option<u32>,
49 /// `/dev/dri/cardN` symlink target if this device is bound to a
50 /// DRM master node.
51 pub drm_node: Option<PathBuf>,
52 /// `/dev/dri/renderD12N` target if a render-only node is exposed.
53 pub render_node: Option<PathBuf>,
54 /// Every `hwmon` instance attached under this PCI device's sysfs
55 /// subtree. Most GPUs surface temperature, fan, and power sensors
56 /// here; integrated parts and BMC framebuffers usually don't.
57 pub hwmon: Vec<HwmonSensor>,
58 pub kind: DeviceKind,
59 }
60
61 /// PCI bus address in the canonical `DDDD:BB:DD.F` shape.
62 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
63 pub struct PciBusAddress {
64 pub segment: u16,
65 pub bus: u8,
66 pub device: u8,
67 pub function: u8,
68 }
69
70 impl core::fmt::Display for PciBusAddress {
71 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
72 write!(
73 f,
74 "{:04x}:{:02x}:{:02x}.{}",
75 self.segment, self.bus, self.device, self.function
76 )
77 }
78 }
79
80 /// PCI class/subclass/prog-if as raw bytes from sysfs.
81 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
82 pub struct PciClass {
83 pub base: u8,
84 pub sub: u8,
85 pub prog_if: u8,
86 }
87
88 /// PCI vendor / device / subsystem IDs.
89 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
90 pub struct PciIds {
91 pub vendor: u16,
92 pub device: u16,
93 pub subsystem_vendor: Option<u16>,
94 pub subsystem_device: Option<u16>,
95 }
96
97 /// Coarse classification derived from PCI class bytes alone — no
98 /// vendor table, no driver introspection. Finer distinctions
99 /// (discrete vs integrated vs BMC framebuffer) need a real probe and
100 /// belong in `appraise`.
101 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
102 pub enum DeviceKind {
103 /// PCI class 0x03 sub 0x00 — VGA-compatible display controller.
104 /// Covers integrated GPUs, consumer dGPUs with a display path,
105 /// and BMC framebuffers.
106 VgaController,
107 /// PCI class 0x03 sub 0x02 — display controller with no VGA
108 /// compatibility. Typical of compute-only cards (Tesla P40, A40,
109 /// many datacenter parts).
110 ComputeGpu,
111 /// PCI class 0x03 anything else.
112 OtherDisplay,
113 /// PCI class 0x12 — processing accelerator. Typically TPU, NPU,
114 /// or non-GPU compute card.
115 Accelerator,
116 /// Anything else. Included so callers can build a complete bus
117 /// view, not just a GPU view.
118 Other,
119 }
120
121 impl DeviceKind {
122 /// True for classes the scheduler and probe layers care about.
123 #[must_use]
124 pub const fn is_compute_candidate(self) -> bool {
125 matches!(
126 self,
127 Self::ComputeGpu | Self::VgaController | Self::Accelerator
128 )
129 }
130 }
131
132 /// One hwmon sensor group attached to a device.
133 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
134 pub struct HwmonSensor {
135 /// Contents of `<hwmon>/name` — e.g. `amdgpu`, `nvme`, `nvidia`,
136 /// `aspeed`. Lets callers route to vendor-specific reads without
137 /// hard-coding paths here.
138 pub name: String,
139 pub sysfs_path: PathBuf,
140 }
141
142 /// Options that shape what `enumerate` returns.
143 #[derive(Clone, Copy, Debug, Default)]
144 pub struct InventoryConfig {
145 /// When true, every PCI function is returned. When false, the
146 /// inventory is filtered to compute candidates (display
147 /// controllers and accelerators). Default false.
148 pub include_non_compute: bool,
149 }
150
151 /// Walk the system and produce a `DeviceInventory`. Linux-only; on
152 /// other targets returns an empty inventory and an `unknown` host
153 /// fingerprint so consumers compile and run unchanged.
154 ///
155 /// # Errors
156 ///
157 /// Returns the first I/O error encountered walking `/sys/bus/pci/`.
158 /// Per-device parse failures are swallowed silently — a malformed
159 /// sysfs file for one card should not erase the whole inventory.
160 pub fn enumerate(config: InventoryConfig) -> std::io::Result<DeviceInventory> {
161 Ok(DeviceInventory {
162 probed_at: SystemTime::now(),
163 host: host_fingerprint(),
164 devices: linux::enumerate_devices(config)?,
165 })
166 }
167
168 /// Read host-level identity. Linux fields read from `/etc/hostname`,
169 /// `/proc/sys/kernel/osrelease`, and `/proc/cpuinfo`. Non-Linux
170 /// targets return all-`unknown`.
171 #[must_use]
172 pub fn host_fingerprint() -> HostFingerprint {
173 HostFingerprint {
174 hostname: linux::read_hostname(),
175 kernel_release: linux::read_kernel_release(),
176 cpu_model: linux::read_cpu_model(),
177 }
178 }
179
180 #[cfg(target_os = "linux")]
181 mod linux {
182 use std::ffi::OsString;
183 use std::fs;
184 use std::io;
185 use std::os::unix::ffi::OsStringExt;
186 use std::path::{Path, PathBuf};
187
188 use super::{
189 DeviceKind, EnumeratedDevice, HwmonSensor, InventoryConfig, PciBusAddress, PciClass, PciIds,
190 };
191
192 const UNKNOWN: &str = "unknown";
193
194 pub(super) fn enumerate_devices(config: InventoryConfig) -> io::Result<Vec<EnumeratedDevice>> {
195 let root = Path::new("/sys/bus/pci/devices");
196 let entries = match fs::read_dir(root) {
197 Ok(it) => it,
198 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
199 Err(e) => return Err(e),
200 };
201
202 let mut out = Vec::new();
203 for entry in entries.flatten() {
204 let Some(dev) = read_device(&entry.path()) else {
205 continue;
206 };
207 if !config.include_non_compute && !dev.kind.is_compute_candidate() {
208 continue;
209 }
210 out.push(dev);
211 }
212 out.sort_by_key(|d| {
213 (
214 d.bus_address.segment,
215 d.bus_address.bus,
216 d.bus_address.device,
217 d.bus_address.function,
218 )
219 });
220 Ok(out)
221 }
222
223 fn read_device(path: &Path) -> Option<EnumeratedDevice> {
224 let name = path.file_name()?.to_str()?;
225 let bus_address = parse_bus_address(name)?;
226 let class_raw = read_hex_u32(&path.join("class"))?;
227 let class = PciClass {
228 base: ((class_raw >> 16) & 0xff) as u8,
229 sub: ((class_raw >> 8) & 0xff) as u8,
230 prog_if: (class_raw & 0xff) as u8,
231 };
232 let ids = PciIds {
233 vendor: read_hex_u16(&path.join("vendor"))?,
234 device: read_hex_u16(&path.join("device"))?,
235 subsystem_vendor: read_hex_u16(&path.join("subsystem_vendor")),
236 subsystem_device: read_hex_u16(&path.join("subsystem_device")),
237 };
238 let driver = read_link_basename(&path.join("driver"));
239 let numa_node = read_trimmed(&path.join("numa_node")).and_then(|s| s.parse::<i16>().ok());
240 let iommu_group =
241 read_link_basename(&path.join("iommu_group")).and_then(|s| s.parse::<u32>().ok());
242 let (drm_node, render_node) = read_drm_nodes(&path.join("drm"));
243 let hwmon = read_hwmon(&path.join("hwmon"));
244 let kind = classify(class);
245 Some(EnumeratedDevice {
246 bus_address,
247 class,
248 ids,
249 driver,
250 numa_node,
251 iommu_group,
252 drm_node,
253 render_node,
254 hwmon,
255 kind,
256 })
257 }
258
259 fn parse_bus_address(s: &str) -> Option<PciBusAddress> {
260 // DDDD:BB:DD.F
261 let (head, function) = s.split_once('.')?;
262 let mut parts = head.split(':');
263 let segment = u16::from_str_radix(parts.next()?, 16).ok()?;
264 let bus = u8::from_str_radix(parts.next()?, 16).ok()?;
265 let device = u8::from_str_radix(parts.next()?, 16).ok()?;
266 if parts.next().is_some() {
267 return None;
268 }
269 let function = function.parse::<u8>().ok()?;
270 Some(PciBusAddress {
271 segment,
272 bus,
273 device,
274 function,
275 })
276 }
277
278 fn classify(class: PciClass) -> DeviceKind {
279 match (class.base, class.sub) {
280 (0x03, 0x00) => DeviceKind::VgaController,
281 (0x03, 0x02) => DeviceKind::ComputeGpu,
282 (0x03, _) => DeviceKind::OtherDisplay,
283 (0x12, _) => DeviceKind::Accelerator,
284 _ => DeviceKind::Other,
285 }
286 }
287
288 fn read_drm_nodes(drm_dir: &Path) -> (Option<PathBuf>, Option<PathBuf>) {
289 let mut card = None;
290 let mut render = None;
291 let Ok(entries) = fs::read_dir(drm_dir) else {
292 return (None, None);
293 };
294 for entry in entries.flatten() {
295 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
296 continue;
297 };
298 if name.starts_with("card") && name.len() > 4 && card.is_none() {
299 card = Some(PathBuf::from("/dev/dri").join(&name));
300 } else if name.starts_with("renderD") && render.is_none() {
301 render = Some(PathBuf::from("/dev/dri").join(&name));
302 }
303 }
304 (card, render)
305 }
306
307 fn read_hwmon(hwmon_dir: &Path) -> Vec<HwmonSensor> {
308 let Ok(entries) = fs::read_dir(hwmon_dir) else {
309 return Vec::new();
310 };
311 let mut out = Vec::new();
312 for entry in entries.flatten() {
313 let path = entry.path();
314 let Some(name) = read_trimmed(&path.join("name")) else {
315 continue;
316 };
317 out.push(HwmonSensor {
318 name,
319 sysfs_path: path,
320 });
321 }
322 out
323 }
324
325 fn read_trimmed(path: &Path) -> Option<String> {
326 fs::read_to_string(path)
327 .ok()
328 .map(|s| s.trim().to_owned())
329 .filter(|s| !s.is_empty())
330 }
331
332 fn read_hex_u32(path: &Path) -> Option<u32> {
333 let s = read_trimmed(path)?;
334 let stripped = s.strip_prefix("0x").unwrap_or(&s);
335 u32::from_str_radix(stripped, 16).ok()
336 }
337
338 fn read_hex_u16(path: &Path) -> Option<u16> {
339 let s = read_trimmed(path)?;
340 let stripped = s.strip_prefix("0x").unwrap_or(&s);
341 u16::from_str_radix(stripped, 16).ok()
342 }
343
344 fn read_link_basename(link: &Path) -> Option<String> {
345 let target = fs::read_link(link).ok()?;
346 let bytes = target.into_os_string().into_vec();
347 let name = OsString::from_vec(bytes);
348 Path::new(&name)
349 .file_name()
350 .and_then(|n| n.to_str())
351 .map(str::to_owned)
352 }
353
354 pub(super) fn read_hostname() -> String {
355 read_trimmed(Path::new("/etc/hostname")).unwrap_or_else(|| UNKNOWN.to_owned())
356 }
357
358 pub(super) fn read_kernel_release() -> String {
359 read_trimmed(Path::new("/proc/sys/kernel/osrelease")).unwrap_or_else(|| UNKNOWN.to_owned())
360 }
361
362 pub(super) fn read_cpu_model() -> String {
363 // Matches the appraise-side reader: x86_64 surfaces "model
364 // name"; aarch64 splits across "Model", "Hardware", "CPU part".
365 const KEYS: &[&str] = &["model name", "Model", "Hardware", "CPU part"];
366 let Ok(content) = fs::read_to_string("/proc/cpuinfo") else {
367 return UNKNOWN.to_owned();
368 };
369 for line in content.lines() {
370 for key in KEYS {
371 if let Some(rest) = line.strip_prefix(key)
372 && let Some(value) = rest.split(':').nth(1)
373 {
374 let trimmed = value.trim();
375 if !trimmed.is_empty() {
376 return trimmed.to_owned();
377 }
378 }
379 }
380 }
381 UNKNOWN.to_owned()
382 }
383 }
384
385 #[cfg(not(target_os = "linux"))]
386 mod linux {
387 use super::{EnumeratedDevice, InventoryConfig};
388
389 const UNKNOWN: &str = "unknown";
390
391 // Signature matches the Linux variant so the public `enumerate`
392 // surface stays portable; the Result is structurally needed even
393 // though this body never errors.
394 #[allow(clippy::unnecessary_wraps)]
395 pub(super) fn enumerate_devices(
396 _config: InventoryConfig,
397 ) -> std::io::Result<Vec<EnumeratedDevice>> {
398 Ok(Vec::new())
399 }
400
401 pub(super) fn read_hostname() -> String {
402 UNKNOWN.to_owned()
403 }
404
405 pub(super) fn read_kernel_release() -> String {
406 UNKNOWN.to_owned()
407 }
408
409 pub(super) fn read_cpu_model() -> String {
410 UNKNOWN.to_owned()
411 }
412 }
413
414 #[cfg(test)]
415 mod tests {
416 use super::*;
417
418 #[test]
419 fn bus_address_round_trips() {
420 let a = PciBusAddress {
421 segment: 0x0000,
422 bus: 0x01,
423 device: 0x00,
424 function: 0,
425 };
426 assert_eq!(a.to_string(), "0000:01:00.0");
427 let b = PciBusAddress {
428 segment: 0x0003,
429 bus: 0x02,
430 device: 0x00,
431 function: 0,
432 };
433 assert_eq!(b.to_string(), "0003:02:00.0");
434 }
435
436 #[test]
437 fn device_kind_compute_filter() {
438 assert!(DeviceKind::ComputeGpu.is_compute_candidate());
439 assert!(DeviceKind::VgaController.is_compute_candidate());
440 assert!(DeviceKind::Accelerator.is_compute_candidate());
441 assert!(!DeviceKind::OtherDisplay.is_compute_candidate());
442 assert!(!DeviceKind::Other.is_compute_candidate());
443 }
444
445 #[test]
446 fn enumerate_compiles_and_runs() {
447 // On non-Linux this returns empty; on Linux it walks /sys.
448 // Either is acceptable — the test just exercises the surface.
449 let inv = enumerate(InventoryConfig::default()).expect("enumerate should not error");
450 assert!(!inv.host.hostname.is_empty());
451 }
452 }
453