Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
        }
    }
}