crates/nornir-bench/src/machine.rs
//! **What the box IS** — the stable identity of the machine a run was measured
//! on, as opposed to [`crate::telemetry`], which records what that particular
//! run EXPERIENCED.
//!
//! The split is deliberate and must stay:
//!
//! | | scope | changes |
//! |---|---|---|
//! | [`Machine`] | the HOST | only when the hardware or the OS changes |
//! | [`crate::telemetry::Telemetry`] | the RUN | every run |
//!
//! Before this module a [`crate::BenchRun`] carried exactly two facts about the
//! host: `machine: String` (a name, from `NORNIR_MACHINE` or the hostname) and
//! `cores: u32`. There was no CPU model, no memory size, no GPU, no kernel and
//! no OS anywhere in the crate — a reading of "holger is slower on box B" could
//! not be checked against what box B actually is.
//!
//! **Pure Rust, no shelling out** (nordisk LAW). Every field is read from
//! `/proc`, `/sys` or `/etc/os-release`. Nothing here spawns a process; there is
//! no `lscpu`, no `nvidia-smi`, no `uname(1)`.
//!
//! Every field is best-effort. A fact that could not be read is spelled as
//! [`UNKNOWN`] (strings) or `0` (counts) — and, for the GPU, as the explicit
//! [`Gpu::None`] variant, because "this box has no GPU" is a real answer and an
//! empty string would be indistinguishable from "we forgot to look".
use std::collections::BTreeSet;
use std::path::Path;
use serde::{Deserialize, Serialize};
/// The value every string field takes when its source file could not be read.
/// Spelled out rather than left empty so a reader can tell a missing
/// measurement from an empty one.
pub const UNKNOWN: &str = "unknown";
/// What the box IS. One of these is stamped onto every [`crate::BenchRun`] at
/// run time by the PRODUCER, so the receipt is self-describing on a machine
/// that never reaches a warehouse.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Machine {
/// `/proc/sys/kernel/hostname`. The box's own name for itself — NOT
/// [`crate::BenchRun::machine`], which is the operator's label (from
/// `NORNIR_MACHINE`) and may be suffixed `-preview`.
pub hostname: String,
/// `model name` from `/proc/cpuinfo`, e.g. `AMD Ryzen 9 7945HX with Radeon
/// Graphics`.
pub cpu_model: String,
/// Distinct `(physical id, core id)` pairs in `/proc/cpuinfo`. 0 when the
/// file does not expose them.
pub cores_physical: u32,
/// Count of `processor:` entries in `/proc/cpuinfo` — logical CPUs, i.e.
/// cores × SMT threads.
pub threads_logical: u32,
/// `MemTotal` from `/proc/meminfo`, in MB (1 MB = 1024 KB, matching the
/// file's own KB unit).
pub mem_total_mb: u64,
/// The graphics devices found under `/sys/class/drm`. See [`Gpu`] — absence
/// is its own variant.
pub gpu: Gpu,
/// `/proc/sys/kernel/osrelease`, e.g. `7.0.0-31-generic`.
pub kernel: String,
/// `PRETTY_NAME` from `/etc/os-release`, e.g. `Ubuntu 26.04 LTS`.
pub os: String,
}
/// The GPU answer. **Absence is a variant, not an empty string**: a box with no
/// discrete or integrated GPU reports [`Gpu::None`], and a box where the scan
/// could not run at all reports [`Gpu::Unknown`]. Those are different facts and
/// a bench reading may depend on which one it is.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Gpu {
/// `/sys/class/drm` was readable and held no card. The box has no GPU.
None,
/// `/sys/class/drm` could not be read (not Linux, container without sysfs).
/// We do not know whether there is a GPU.
Unknown,
/// One entry per `cardN` found, in `cardN` order.
Devices(Vec<GpuDevice>),
}
/// One graphics device, read from `/sys/class/drm/cardN/device/`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GpuDevice {
/// `cardN`.
pub card: String,
/// `DRIVER=` from the device's `uevent` (`amdgpu`, `i915`, `nvidia`, …), or
/// [`UNKNOWN`].
pub driver: String,
/// PCI vendor, resolved to a name when we know the id, else the raw
/// `0x____`.
pub vendor: String,
/// Raw PCI device id, `0x____`.
pub device: String,
}
impl Machine {
/// Read the box. Never fails: a source that cannot be read yields
/// [`UNKNOWN`] / `0` / [`Gpu::Unknown`] for the fields it feeds.
pub fn detect() -> Machine {
Machine::detect_under(Path::new("/"))
}
/// [`Machine::detect`] against an arbitrary filesystem root, so the tests
/// can point it at a fixture tree instead of the real box.
pub fn detect_under(root: &Path) -> Machine {
let read = |rel: &str| -> Option<String> { std::fs::read_to_string(root.join(rel)).ok() };
let hostname = read("proc/sys/kernel/hostname")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| UNKNOWN.to_string());
let kernel = read("proc/sys/kernel/osrelease")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| UNKNOWN.to_string());
let os = read("etc/os-release")
.and_then(|s| os_pretty_name(&s))
.unwrap_or_else(|| UNKNOWN.to_string());
let cpuinfo = read("proc/cpuinfo").unwrap_or_default();
let cpu = parse_cpuinfo(&cpuinfo);
let mem_total_mb = read("proc/meminfo")
.and_then(|s| parse_mem_total_kb(&s))
.map(|kb| kb / 1024)
.unwrap_or(0);
Machine {
hostname,
cpu_model: cpu.model,
cores_physical: cpu.cores_physical,
threads_logical: cpu.threads_logical,
mem_total_mb,
gpu: scan_gpu(&root.join("sys/class/drm")),
kernel,
os,
}
}
}
/// The three facts `/proc/cpuinfo` carries about the CPU itself.
struct CpuFacts {
model: String,
cores_physical: u32,
threads_logical: u32,
}
/// Parse `/proc/cpuinfo`: the first `model name`, the count of `processor`
/// entries (logical threads) and the count of distinct `(physical id, core id)`
/// pairs (physical cores).
///
/// The pair set is what makes SMT visible. On a 16-core/32-thread box it yields
/// 16 and 32; on a VM whose `/proc/cpuinfo` omits `core id` it yields 0 and the
/// thread count — and 0 is honest, because the file did not say.
fn parse_cpuinfo(s: &str) -> CpuFacts {
let mut model = None::<String>;
let mut threads = 0u32;
let mut pairs: BTreeSet<(String, String)> = BTreeSet::new();
let mut cur_phys = None::<String>;
let mut cur_core = None::<String>;
let mut flush = |phys: &mut Option<String>, core: &mut Option<String>| {
if let (Some(p), Some(c)) = (phys.take(), core.take()) {
pairs.insert((p, c));
} else {
*phys = None;
*core = None;
}
};
for line in s.lines() {
if line.trim().is_empty() {
flush(&mut cur_phys, &mut cur_core);
continue;
}
let Some((k, v)) = line.split_once(':') else {
continue;
};
let (k, v) = (k.trim(), v.trim());
match k {
"processor" => threads += 1,
"model name" if model.is_none() => model = Some(v.to_string()),
"physical id" => cur_phys = Some(v.to_string()),
"core id" => cur_core = Some(v.to_string()),
_ => {}
}
}
flush(&mut cur_phys, &mut cur_core);
CpuFacts {
model: model.unwrap_or_else(|| UNKNOWN.to_string()),
cores_physical: pairs.len() as u32,
threads_logical: threads,
}
}
/// `MemTotal: 65183372 kB` → `65183372`.
fn parse_mem_total_kb(s: &str) -> Option<u64> {
for line in s.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
return rest.split_whitespace().next()?.parse().ok();
}
}
None
}
/// `PRETTY_NAME="Ubuntu 26.04 LTS"` → `Ubuntu 26.04 LTS`.
fn os_pretty_name(s: &str) -> Option<String> {
for line in s.lines() {
if let Some(v) = line.strip_prefix("PRETTY_NAME=") {
let v = v.trim().trim_matches('"').to_string();
if !v.is_empty() {
return Some(v);
}
}
}
None
}
/// Walk `/sys/class/drm` and describe every `cardN`. Reading sysfs directly is
/// what keeps this pure Rust; `nvidia-smi`/`lspci` would be shelling out, which
/// the law forbids.
///
/// Render nodes (`renderD128`) and connector entries (`card0-DP-1`) are skipped:
/// only the bare `cardN` directories are devices.
fn scan_gpu(drm: &Path) -> Gpu {
let Ok(entries) = std::fs::read_dir(drm) else {
return Gpu::Unknown;
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| is_bare_card(n))
.collect();
names.sort();
let mut out = Vec::new();
for card in names {
let dev = drm.join(&card).join("device");
let read = |f: &str| -> Option<String> {
std::fs::read_to_string(dev.join(f)).ok().map(|s| s.trim().to_string())
};
let driver = read("uevent")
.and_then(|u| {
u.lines()
.find_map(|l| l.strip_prefix("DRIVER=").map(|d| d.trim().to_string()))
})
.unwrap_or_else(|| UNKNOWN.to_string());
let vendor_raw = read("vendor").unwrap_or_else(|| UNKNOWN.to_string());
out.push(GpuDevice {
card,
driver,
vendor: vendor_name(&vendor_raw),
device: read("device").unwrap_or_else(|| UNKNOWN.to_string()),
});
}
if out.is_empty() {
Gpu::None
} else {
Gpu::Devices(out)
}
}
/// `card0` yes; `card0-DP-1` (a connector) and `renderD128` (a render node) no.
fn is_bare_card(n: &str) -> bool {
let Some(rest) = n.strip_prefix("card") else {
return false;
};
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
}
/// The three PCI vendor ids that account for every GPU in the estate. An
/// unknown id is returned as-is rather than mapped to a guess.
fn vendor_name(raw: &str) -> String {
match raw.to_ascii_lowercase().as_str() {
"0x1002" => "AMD".to_string(),
"0x10de" => "NVIDIA".to_string(),
"0x8086" => "Intel".to_string(),
_ => raw.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cpuinfo_counts_cores_and_threads_apart() {
// Two physical cores, SMT on: four `processor` entries, two distinct
// (physical id, core id) pairs.
let s = "\
processor\t: 0
model name\t: AMD Ryzen 9 7945HX with Radeon Graphics
physical id\t: 0
core id\t: 0
processor\t: 1
model name\t: AMD Ryzen 9 7945HX with Radeon Graphics
physical id\t: 0
core id\t: 0
processor\t: 2
model name\t: AMD Ryzen 9 7945HX with Radeon Graphics
physical id\t: 0
core id\t: 1
processor\t: 3
model name\t: AMD Ryzen 9 7945HX with Radeon Graphics
physical id\t: 0
core id\t: 1
";
let f = parse_cpuinfo(s);
assert_eq!(f.threads_logical, 4);
assert_eq!(f.cores_physical, 2);
assert_eq!(f.model, "AMD Ryzen 9 7945HX with Radeon Graphics");
}
#[test]
fn a_cpuinfo_without_core_ids_reports_zero_physical_not_a_guess() {
// Many hypervisors omit `physical id`/`core id`. 0 says "the file did
// not tell us"; copying the thread count would invent an SMT-free box.
let s = "processor\t: 0\nmodel name\t: QEMU Virtual CPU\n\nprocessor\t: 1\n";
let f = parse_cpuinfo(s);
assert_eq!(f.threads_logical, 2);
assert_eq!(f.cores_physical, 0);
}
#[test]
fn mem_total_and_pretty_name_parse() {
assert_eq!(parse_mem_total_kb("MemFree: 1 kB\nMemTotal: 65183372 kB\n"), Some(65183372));
assert_eq!(
os_pretty_name("NAME=\"Ubuntu\"\nPRETTY_NAME=\"Ubuntu 26.04 LTS\"\n").as_deref(),
Some("Ubuntu 26.04 LTS")
);
}
#[test]
fn a_readable_drm_dir_with_no_card_is_none_not_unknown() {
let d = tempfile::tempdir().unwrap();
// Readable, empty → the box HAS no GPU. Distinct from a missing dir.
assert_eq!(scan_gpu(d.path()), Gpu::None);
assert_eq!(scan_gpu(&d.path().join("nope")), Gpu::Unknown);
}
#[test]
fn render_nodes_and_connectors_are_not_cards() {
assert!(is_bare_card("card0"));
assert!(is_bare_card("card12"));
assert!(!is_bare_card("card0-DP-1"));
assert!(!is_bare_card("renderD128"));
assert!(!is_bare_card("version"));
}
#[test]
fn a_card_is_read_from_sysfs_files_only() {
let d = tempfile::tempdir().unwrap();
let dev = d.path().join("card0").join("device");
std::fs::create_dir_all(&dev).unwrap();
std::fs::write(dev.join("uevent"), "DRIVER=amdgpu\nPCI_ID=1002:164E\n").unwrap();
std::fs::write(dev.join("vendor"), "0x1002\n").unwrap();
std::fs::write(dev.join("device"), "0x164e\n").unwrap();
std::fs::create_dir_all(d.path().join("renderD128")).unwrap();
let Gpu::Devices(v) = scan_gpu(d.path()) else {
panic!("expected one device");
};
assert_eq!(v.len(), 1, "the render node must not become a second GPU");
assert_eq!(v[0].driver, "amdgpu");
assert_eq!(v[0].vendor, "AMD");
assert_eq!(v[0].device, "0x164e");
}
#[test]
fn gpu_absence_serialises_as_the_word_none_not_as_an_empty_string() {
assert_eq!(serde_json::to_string(&Gpu::None).unwrap(), "\"none\"");
assert_eq!(serde_json::to_string(&Gpu::Unknown).unwrap(), "\"unknown\"");
}
#[test]
fn detect_reads_the_real_box_without_spawning_anything() {
let m = Machine::detect();
// On Linux every one of these files exists. The assertion that matters
// is that the call returns at all and that nothing is empty-string.
assert!(!m.hostname.is_empty());
assert!(!m.cpu_model.is_empty());
assert!(!m.kernel.is_empty());
assert!(!m.os.is_empty());
if cfg!(target_os = "linux") {
assert!(m.threads_logical >= 1, "threads_logical={}", m.threads_logical);
assert!(m.mem_total_mb > 0, "mem_total_mb={}", m.mem_total_mb);
}
}
}