metrale_bench/
hardware.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The hardware fingerprint: which box a benchmark number was
4//! measured on, and the box-class key gate baselines are indexed by.
5//!
6//! * [`Hardware`] names the box. A gate record's copy comes from the serving
7//!   endpoint's `/hardware` (`http::fetch_hardware`), so it describes the
8//!   serving box even when the benchmark CLI runs elsewhere;
9//!   [`Hardware::probe`] reads the local box.
10//! * [`state::HardwareState`] records the box's state. The executor collects
11//!   it before a run and, unless that capture fails or the pre-check refuses
12//!   the run, after; [`policy`] decides what is gated on it.
13//!
14//! Owner: bench (hardware).
15//! Invariants:
16//! - [`Hardware::probe`] never fails; with no tool answering it returns
17//!   [`Hardware::unknown`].
18//! - [`Hardware::gate_key`] is never empty: a name that yields no key gives
19//!   `"unknown"`.
20
21use serde::{Deserialize, Serialize};
22
23pub mod collect;
24pub mod energy;
25pub mod energy_sampler;
26pub mod equivalence;
27pub mod ids;
28pub mod limits;
29pub mod parse;
30pub mod policy;
31pub mod report;
32pub mod state;
33pub mod throttle_monitor;
34
35pub use policy::{Decision, Sensitivity, Validity};
36pub use report::HardwareStateReport;
37pub use state::{HardwareState, HardwareStateDelta};
38
39/// 2026-09-26: A box's fingerprint, as its serving endpoint reported it or
40/// as [`Hardware::probe`] read it.
41#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
42pub struct Hardware {
43    /// 2026-09-26: Device model, e.g. "NVIDIA GB10", or `pci:<vendor>:<device>`
44    /// from sysfs. Empty when unknown.
45    #[serde(default)]
46    pub gpu: String,
47    /// 2026-09-26: Driver version, e.g. "580.126.09". Empty when unknown.
48    #[serde(default)]
49    pub driver: String,
50    /// 2026-09-26: GPU clock in MHz at probe time: nvidia-smi's `clocks.sm`,
51    /// or the clock `rocm-smi --showclocks` reports. `None` for `[N/A]`, an
52    /// unparseable cell, and every sysfs reading.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub sm_clock_mhz: Option<f64>,
55    /// 2026-09-26: How many GPUs the box exposed at probe time, counted from
56    /// `nvidia-smi -L` (`parse::gpu_count`); `gpu` names only the first. `None`
57    /// means unmeasured, never one: rocm-smi and sysfs leave it `None`, and it
58    /// is left out of the JSON.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub gpu_count: Option<u32>,
61    /// 2026-09-26: Where the reading came from ("nvidia-smi", "rocm-smi",
62    /// "sysfs"). Empty when unknown.
63    #[serde(default)]
64    pub source: String,
65}
66
67impl Hardware {
68    pub fn unknown() -> Self {
69        Self::default()
70    }
71
72    /// 2026-09-26: The box-class key a gate baseline is indexed by, e.g.
73    /// `"gb10"`: a class, not a host.
74    ///
75    /// A name with a token in the SKU table ([`ids::hardware_id_from_gpu_name`])
76    /// gets that id, so `"NVIDIA H100 80GB HBM3"` is `h100`. Any other name is
77    /// lowercased with `nvidia` and `amd` removed and only alphanumerics kept.
78    /// An empty `gpu`, or a name that leaves nothing, gives `"unknown"`, which
79    /// no baseline defines; `fetch_hardware` returns [`Hardware::unknown`] on
80    /// every error path without surfacing one.
81    pub fn gate_key(&self) -> String {
82        if self.gpu.is_empty() {
83            return "unknown".to_string();
84        }
85        if let Some(id) = ids::hardware_id_from_gpu_name(&self.gpu) {
86            return id.to_string();
87        }
88        let key: String = self
89            .gpu
90            .to_lowercase()
91            .replace("nvidia", "")
92            .replace("amd", "")
93            .chars()
94            .filter(|c| c.is_alphanumeric())
95            .collect();
96        if key.is_empty() {
97            "unknown".to_string()
98        } else {
99            key
100        }
101    }
102
103    /// 2026-09-26: True when `gpu`, `driver` and `sm_clock_mhz` are all empty;
104    /// `gpu_count` and `source` are not consulted.
105    pub fn is_unknown(&self) -> bool {
106        self.gpu.is_empty() && self.driver.is_empty() && self.sm_clock_mhz.is_none()
107    }
108
109    /// 2026-09-26: One-line summary for reports, e.g. "NVIDIA GB10 · driver
110    /// 580.126.09 · sm 208 MHz"; a GPU count above one follows the part as
111    /// `×n`.
112    pub fn one_line(&self) -> String {
113        if self.is_unknown() {
114            return "unknown hardware".to_string();
115        }
116        let mut parts = Vec::new();
117        if !self.gpu.is_empty() {
118            parts.push(match self.gpu_count {
119                Some(n) if n > 1 => format!("{} ×{n}", self.gpu),
120                _ => self.gpu.clone(),
121            });
122        }
123        if !self.driver.is_empty() {
124            parts.push(format!("driver {}", self.driver));
125        }
126        if let Some(clock) = self.sm_clock_mhz {
127            parts.push(format!("sm {clock:.0} MHz"));
128        }
129        parts.join(" · ")
130    }
131
132    /// 2026-09-26: Probe the local box: `nvidia-smi`, then `rocm-smi`, then
133    /// sysfs, returning the first that answers, or [`Hardware::unknown`].
134    pub fn probe() -> Self {
135        nvidia_smi()
136            .or_else(rocm_smi)
137            .or_else(sysfs)
138            .unwrap_or_else(Self::unknown)
139    }
140}
141
142/// 2026-09-26: A tool's trimmed stdout; `None` when it cannot start, exits
143/// non-zero or prints nothing.
144fn run(tool: &str, args: &[&str]) -> Option<String> {
145    let out = std::process::Command::new(tool)
146        .args(args)
147        .stdin(std::process::Stdio::null())
148        .output()
149        .ok()?;
150    out.status
151        .success()
152        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
153        .filter(|s| !s.is_empty())
154}
155
156/// 2026-09-26: Name, driver and SM clock of the first GPU from one CSV query
157/// (an `[N/A]` clock is `None`), and the GPU count from `nvidia-smi -L`.
158fn nvidia_smi() -> Option<Hardware> {
159    let line = run(
160        "nvidia-smi",
161        &[
162            "--query-gpu=name,driver_version,clocks.sm",
163            "--format=csv,noheader,nounits",
164        ],
165    )?
166    .lines()
167    .next()?
168    .to_string();
169    let mut cells = line.split(',').map(str::trim);
170    let gpu = cells.next().unwrap_or_default().to_string();
171    let driver = cells.next().unwrap_or_default().to_string();
172    let sm_clock_mhz = cells
173        .next()
174        .filter(|v| *v != "[N/A]")
175        .and_then(|v| v.parse().ok());
176    let gpu_count = run("nvidia-smi", &["-L"]).and_then(|out| parse::gpu_count(&out));
177    Some(Hardware {
178        gpu,
179        driver,
180        sm_clock_mhz,
181        gpu_count,
182        source: "nvidia-smi".into(),
183    })
184}
185
186/// 2026-09-26: rocm-smi: one call for the card and driver, one for the clock.
187/// The GPU count is left `None`.
188fn rocm_smi() -> Option<Hardware> {
189    let card = run(
190        "rocm-smi",
191        &["--showproductname", "--showdriverversion", "--csv"],
192    )?;
193    let mut gpu = String::new();
194    let mut driver = String::new();
195    for line in card.lines().skip(1) {
196        let cells: Vec<&str> = line.split(',').map(str::trim).collect();
197        if cells.len() >= 3 {
198            gpu = cells[1].to_string();
199            driver = cells[2].to_string();
200            break;
201        }
202    }
203    let sm_clock_mhz = run("rocm-smi", &["--showclocks", "--csv"]).and_then(|out| {
204        out.lines()
205            .find_map(|l| l.split(',').nth(2))
206            .and_then(|v| v.trim().parse().ok())
207    });
208    Some(Hardware {
209        gpu,
210        driver,
211        sm_clock_mhz,
212        gpu_count: None,
213        source: "rocm-smi".into(),
214    })
215}
216
217/// 2026-09-26: Last resort: vendor and device ids of the first device under
218/// `/sys/bus/pci/devices` whose class starts with `0x03`, with no driver or
219/// clock.
220fn sysfs() -> Option<Hardware> {
221    for entry in std::fs::read_dir("/sys/bus/pci/devices").ok()?.flatten() {
222        let path = entry.path();
223        let Ok(class) = std::fs::read_to_string(path.join("class")) else {
224            continue;
225        };
226        if !class.trim().starts_with("0x03") {
227            continue;
228        }
229        let vendor = std::fs::read_to_string(path.join("vendor")).unwrap_or_default();
230        let device = std::fs::read_to_string(path.join("device")).unwrap_or_default();
231        return Some(Hardware {
232            gpu: format!("pci:{}:{}", vendor.trim(), device.trim()),
233            source: "sysfs".into(),
234            ..Hardware::default()
235        });
236    }
237    None
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn round_trip_keeps_every_field() {
246        let hw = Hardware {
247            gpu: "NVIDIA GB10".into(),
248            driver: "580.126.09".into(),
249            sm_clock_mhz: Some(208.0),
250            gpu_count: Some(1),
251            source: "nvidia-smi".into(),
252        };
253        let back: Hardware = serde_json::from_str(&serde_json::to_string(&hw).unwrap()).unwrap();
254        assert_eq!(hw, back);
255    }
256
257    #[test]
258    fn missing_fields_default_to_unknown() {
259        let hw: Hardware = serde_json::from_str("{}").unwrap();
260        assert_eq!(hw, Hardware::unknown());
261        assert_eq!(hw.one_line(), "unknown hardware");
262    }
263
264    #[test]
265    fn one_line_lists_each_reported_measurement() {
266        let hw = Hardware {
267            gpu: "NVIDIA GB10".into(),
268            driver: "580.126.09".into(),
269            sm_clock_mhz: Some(208.0),
270            gpu_count: None,
271            source: "nvidia-smi".into(),
272        };
273        assert_eq!(
274            hw.one_line(),
275            "NVIDIA GB10 · driver 580.126.09 · sm 208 MHz"
276        );
277        assert_eq!(
278            Hardware {
279                gpu: hw.gpu.clone(),
280                ..Hardware::default()
281            }
282            .one_line(),
283            "NVIDIA GB10"
284        );
285        assert_eq!(
286            Hardware {
287                driver: hw.driver.clone(),
288                ..Hardware::default()
289            }
290            .one_line(),
291            "driver 580.126.09"
292        );
293        assert_eq!(
294            Hardware {
295                sm_clock_mhz: hw.sm_clock_mhz,
296                ..Hardware::default()
297            }
298            .one_line(),
299            "sm 208 MHz"
300        );
301    }
302    /// 2026-09-26: A count above one appears as `×n` after the part; a count
303    /// of one and an unmeasured count add nothing.
304    #[test]
305    fn a_multi_gpu_box_names_its_width_and_a_single_one_stays_quiet() {
306        let node = |n| Hardware {
307            gpu: "NVIDIA H100 80GB HBM3".into(),
308            driver: "580.126.09".into(),
309            gpu_count: n,
310            ..Hardware::default()
311        };
312        assert_eq!(
313            node(Some(8)).one_line(),
314            "NVIDIA H100 80GB HBM3 ×8 · driver 580.126.09"
315        );
316        assert_eq!(
317            node(Some(1)).one_line(),
318            "NVIDIA H100 80GB HBM3 · driver 580.126.09"
319        );
320        assert_eq!(
321            node(None).one_line(),
322            "NVIDIA H100 80GB HBM3 · driver 580.126.09"
323        );
324    }
325
326    /// 2026-09-26: `gpu_count` round-trips through JSON, and an unmeasured
327    /// count is absent from it.
328    #[test]
329    fn the_gpu_count_round_trips_and_stays_out_of_the_json_when_unmeasured() {
330        let eight = Hardware {
331            gpu: "NVIDIA H200".into(),
332            gpu_count: Some(8),
333            ..Hardware::default()
334        };
335        let json = serde_json::to_string(&eight).unwrap();
336        assert_eq!(
337            serde_json::from_str::<Hardware>(&json).unwrap().gpu_count,
338            Some(8)
339        );
340        let unmeasured = Hardware {
341            gpu: "NVIDIA GB10".into(),
342            ..Hardware::default()
343        };
344        let json = serde_json::to_string(&unmeasured).unwrap();
345        assert!(!json.contains("gpu_count"), "{json}");
346        assert_eq!(
347            serde_json::from_str::<Hardware>(&json).unwrap().gpu_count,
348            None
349        );
350    }
351}
352
353#[cfg(test)]
354#[path = "hardware_gate_key_tests.rs"]
355mod gate_key_tests;