metrale_bench/hardware/
collect.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The I/O half of the hardware-state collector: run `nvidia-smi`,
4//! read procfs and sysfs, and hand the text to [`super::parse`], which does
5//! the interpreting.
6//!
7//! A tool that is missing, fails, exits non-zero or prints nothing, and a file
8//! that cannot be read, leave the fields they would have filled at their
9//! defaults, with one exception: a compute-apps query that gives no output is
10//! recorded as an empty list when `nvidia-smi --list-gpus` answers.
11//! [`super::policy`] decides what a missing reading means.
12//!
13//! [`collect`] blocks: it runs subprocesses, and `std::process` has no
14//! timeout. The bench executor calls it through `spawn_blocking`
15//! (`executor.rs`).
16//!
17//! Owner: bench hardware.
18//! Invariants:
19//! - `collect` returns a `HardwareState` on every path; it has no error type.
20//! - `sources` names only sources that answered.
21
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use super::parse;
25use super::state::{HardwareState, MachineIdentity, ThermalZone};
26
27/// 2026-09-26: Run a tool and return its stdout when it exits 0 with
28/// non-blank output; otherwise `None`.
29fn run(tool: &str, args: &[&str]) -> Option<String> {
30    let out = std::process::Command::new(tool)
31        .args(args)
32        .stdin(std::process::Stdio::null())
33        .output()
34        .ok()?;
35    out.status
36        .success()
37        .then(|| String::from_utf8_lossy(&out.stdout).to_string())
38        .filter(|s| !s.trim().is_empty())
39}
40
41fn read(path: &str) -> Option<String> {
42    std::fs::read_to_string(path).ok()
43}
44
45fn now_secs() -> u64 {
46    SystemTime::now()
47        .duration_since(UNIX_EPOCH)
48        .map(|d| d.as_secs())
49        .unwrap_or(0)
50}
51
52/// 2026-09-26: Every readable `/sys/class/thermal/thermal_zone*`, in numeric
53/// zone order; a zone whose `temp` cannot be read or parsed is left out.
54///
55/// `None` when the directory itself cannot be read, which is a different fact
56/// from a box reporting zero zones.
57fn thermal_zones() -> Option<Vec<ThermalZone>> {
58    let mut entries: Vec<_> = std::fs::read_dir("/sys/class/thermal")
59        .ok()?
60        .flatten()
61        .map(|e| e.path())
62        .filter(|p| {
63            p.file_name()
64                .and_then(|n| n.to_str())
65                .is_some_and(|n| n.starts_with("thermal_zone"))
66        })
67        .collect();
68    // 2026-09-26: Numeric, not lexical, order: zone10 sorts after zone2.
69    entries.sort_by_key(|p| {
70        p.file_name()
71            .and_then(|n| n.to_str())
72            .and_then(|n| n.trim_start_matches("thermal_zone").parse::<u32>().ok())
73            .unwrap_or(u32::MAX)
74    });
75    Some(
76        entries
77            .iter()
78            .filter_map(|p| {
79                let temp = std::fs::read_to_string(p.join("temp")).ok()?;
80                let kind = std::fs::read_to_string(p.join("type")).ok();
81                parse::thermal_zone(kind.as_deref(), &temp)
82            })
83            .collect(),
84    )
85}
86
87/// 2026-09-26: The scaling governor of cpu0 only; other cores are not read.
88fn cpu_governor() -> Option<String> {
89    read("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").map(|s| s.trim().to_string())
90}
91
92fn machine() -> MachineIdentity {
93    MachineIdentity {
94        // 2026-09-26: Read from procfs, which costs no subprocess.
95        hostname: read("/proc/sys/kernel/hostname")
96            .map(|s| s.trim().to_string())
97            .filter(|s| !s.is_empty()),
98        machine_id: read("/etc/machine-id")
99            .map(|s| s.trim().to_string())
100            .filter(|s| !s.is_empty()),
101        gpu: None,
102        driver: None,
103    }
104}
105
106/// 2026-09-26: Capture the local box's state. Each source fills only its own
107/// fields, and one that does not answer leaves them at their defaults, except
108/// the compute-apps list (see the module doc).
109pub fn collect() -> HardwareState {
110    let mut sources = Vec::new();
111    let mut state = HardwareState {
112        captured_at: now_secs(),
113        machine: machine(),
114        ..HardwareState::default()
115    };
116
117    if let Some(text) = run(
118        "nvidia-smi",
119        &[
120            "--query-gpu=name,driver_version,clocks.sm,clocks.max.sm,temperature.gpu,persistence_mode",
121            "--format=csv,noheader,nounits",
122        ],
123    ) {
124        let q = parse::gpu_query(&text);
125        state.machine.gpu = q.name;
126        state.machine.driver = q.driver;
127        state.sm_clock_mhz = q.sm_clock_mhz;
128        state.sm_clock_max_mhz = q.sm_clock_max_mhz;
129        state.gpu_temp_c = q.gpu_temp_c;
130        state.persistence_mode = q.persistence_mode;
131        sources.push("nvidia-smi".to_string());
132    }
133
134    if let Some(text) = run("nvidia-smi", &["-q", "-d", "PERFORMANCE"]) {
135        let (counters, active) = parse::performance(&text);
136        state.throttle_counters = counters;
137        state.throttle_active = active;
138        if !sources.iter().any(|s| s == "nvidia-smi") {
139            sources.push("nvidia-smi".to_string());
140        }
141    }
142
143    // 2026-09-26: Per-process `used_memory` from the compute-apps query, not
144    // `--query-gpu=memory.used`, which reads `[N/A]` on GB10 (checked
145    // 2026-09-26).
146    if let Some(text) = run(
147        "nvidia-smi",
148        &[
149            "--query-compute-apps=pid,process_name,used_memory",
150            "--format=csv,noheader,nounits",
151        ],
152    ) {
153        state.gpu_compute_apps = Some(parse::compute_apps(&text));
154    } else if run("nvidia-smi", &["--list-gpus"]).is_some() {
155        // 2026-09-26: The compute-apps query failed or printed nothing, but
156        // `--list-gpus` answers, so this is recorded as an empty list (an idle
157        // GPU). Left `None`, the policy would warn that processes could not be
158        // listed (`policy.rs`).
159        state.gpu_compute_apps = Some(Vec::new());
160    }
161
162    if let Some(text) = read("/proc/meminfo") {
163        let m = parse::meminfo(&text);
164        state.mem_total_kb = m.total_kb;
165        state.mem_available_kb = m.available_kb;
166        state.page_cache_kb = m.cached_kb;
167        sources.push("procfs".to_string());
168    }
169
170    state.chassis_temps_c = thermal_zones();
171    if state.chassis_temps_c.is_some() {
172        sources.push("sysfs".to_string());
173    }
174    state.cpu_governor = cpu_governor();
175
176    state.sources = sources;
177    state
178}