metrale_bench/hardware/
parse.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Pure parsers for the nvidia-smi, procfs and sysfs text that
4//! [`super::collect`] reads.
5//!
6//! Owner: bench hardware.
7//! Invariants:
8//! - `&str` in, data out: no I/O, so the fixtures in `fixtures/` exercise every parser.
9//! - A number that does not parse is `None`, or its row or zone is dropped; no parser
10//!   substitutes a default, so a caller can tell "0 µs of throttling" from "no idea".
11
12use super::state::{GpuComputeApp, ThermalZone, ThrottleActive, ThrottleCounters};
13
14/// 2026-09-26: The trimmed cell, or `None` for a blank cell or one of nvidia-smi's
15/// not-a-value spellings: `[N/A]` (`--format=csv`, `fixtures/gb10_query_gpu_na.csv`),
16/// `N/A` (`-q`, `fixtures/gb10_performance_unsupported.txt`) and `[Not Supported]`.
17fn value(cell: &str) -> Option<&str> {
18    let c = cell.trim();
19    (!c.is_empty() && c != "[N/A]" && c != "N/A" && c != "[Not Supported]").then_some(c)
20}
21
22/// 2026-09-26: The first non-blank CSV row of
23/// `--query-gpu=name,driver_version,clocks.sm,clocks.max.sm,temperature.gpu,persistence_mode`
24/// with `--format=csv,noheader,nounits`.
25///
26/// Fields are positional; a short row leaves the missing tail unknown rather
27/// than shifting values into the wrong slots.
28#[derive(Debug, Default, PartialEq)]
29pub struct GpuQuery {
30    pub name: Option<String>,
31    pub driver: Option<String>,
32    pub sm_clock_mhz: Option<f64>,
33    pub sm_clock_max_mhz: Option<f64>,
34    pub gpu_temp_c: Option<f64>,
35    pub persistence_mode: Option<bool>,
36}
37
38pub fn gpu_query(text: &str) -> GpuQuery {
39    let Some(line) = text.lines().find(|l| !l.trim().is_empty()) else {
40        return GpuQuery::default();
41    };
42    let cells: Vec<&str> = line.split(',').collect();
43    let cell = |i: usize| cells.get(i).copied().and_then(value);
44    let num = |i: usize| {
45        cell(i)
46            .and_then(|c| c.parse::<f64>().ok())
47            .filter(|n| n.is_finite())
48    };
49    GpuQuery {
50        name: cell(0).map(str::to_string),
51        driver: cell(1).map(str::to_string),
52        sm_clock_mhz: num(2),
53        sm_clock_max_mhz: num(3),
54        gpu_temp_c: num(4),
55        // 2026-09-26: Anything that is neither spelling is unknown, not "off".
56        persistence_mode: cell(5).and_then(|c| match c {
57            "Enabled" => Some(true),
58            "Disabled" => Some(false),
59            _ => None,
60        }),
61    }
62}
63
64/// 2026-09-26: The device count from `nvidia-smi -L`, one line per visible device.
65///
66/// ```text
67/// GPU 0: NVIDIA H100 80GB HBM3 (UUID: GPU-2f1c…)
68/// GPU 1: NVIDIA H100 80GB HBM3 (UUID: GPU-9a44…)
69/// ```
70///
71/// Only lines that begin with `GPU ` and contain `:` are counted, so the indented
72/// `  MIG 1g.10gb  Device 0: …` lines of a MIG-partitioned box are not.
73///
74/// `None` when no such line is present: an unreadable answer is not a box with
75/// zero GPUs, and a count of one from a missing tool would claim a single-GPU
76/// topology for a run that may have used more.
77pub fn gpu_count(text: &str) -> Option<u32> {
78    let n = text
79        .lines()
80        .filter(|l| l.starts_with("GPU ") && l.contains(':'))
81        .count();
82    (n > 0).then_some(n as u32)
83}
84
85/// 2026-09-26: The rows of
86/// `--query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits`.
87///
88/// A row whose pid does not parse is dropped rather than guessed at: the count
89/// this feeds can refuse a run. An unparseable memory cell keeps the row with
90/// `used_mib: None`, because the pid is the part the check reads.
91pub fn compute_apps(text: &str) -> Vec<GpuComputeApp> {
92    text.lines()
93        .filter(|l| !l.trim().is_empty())
94        .filter_map(|line| {
95            let cells: Vec<&str> = line.split(',').collect();
96            let pid = value(cells.first()?)?.parse::<u32>().ok()?;
97            Some(GpuComputeApp {
98                pid,
99                name: cells
100                    .get(1)
101                    .and_then(|c| value(c))
102                    .unwrap_or("")
103                    .to_string(),
104                used_mib: cells
105                    .get(2)
106                    .and_then(|c| value(c))
107                    .and_then(|c| c.split_whitespace().next())
108                    .and_then(|c| c.parse().ok()),
109            })
110        })
111        .collect()
112}
113
114/// 2026-09-26: The counters and the asserted reasons from `nvidia-smi -q -d PERFORMANCE`.
115///
116/// The two sections share key names: "SW Thermal Slowdown" appears under
117/// `Clocks Event Reasons` as `Not Active` and under `Clocks Event Reasons
118/// Counters` as `502088297 us` (`fixtures/gb10_performance.txt`). A flat key scan
119/// would read whichever came last, so the section is tracked explicitly. The
120/// counters header is tested first because the reasons header is a prefix of it.
121pub fn performance(text: &str) -> (ThrottleCounters, ThrottleActive) {
122    let (mut counters, mut active) = (ThrottleCounters::default(), ThrottleActive::default());
123    let mut in_counters = false;
124    let mut in_reasons = false;
125    for line in text.lines() {
126        let trimmed = line.trim();
127        if trimmed.starts_with("Clocks Event Reasons Counters") {
128            (in_counters, in_reasons) = (true, false);
129            continue;
130        }
131        if trimmed.starts_with("Clocks Event Reasons") {
132            (in_counters, in_reasons) = (false, true);
133            continue;
134        }
135        let Some((key, raw)) = trimmed.split_once(':') else {
136            // 2026-09-26: A non-blank line with no `:` ends both sections, so
137            // keys under a later heading are not read as clock reasons.
138            if !trimmed.is_empty() {
139                (in_counters, in_reasons) = (false, false);
140            }
141            continue;
142        };
143        let key = key.trim();
144        let Some(raw) = value(raw) else { continue };
145        if in_counters {
146            let us = raw
147                .split_whitespace()
148                .next()
149                .and_then(|n| n.parse::<u64>().ok());
150            match key {
151                "SW Power Capping" => counters.sw_power_cap_us = us,
152                "SW Thermal Slowdown" => counters.sw_thermal_us = us,
153                "HW Thermal Slowdown" => counters.hw_thermal_us = us,
154                "HW Power Braking" => counters.hw_power_brake_us = us,
155                "Sync Boost" => counters.sync_boost_us = us,
156                _ => {}
157            }
158        } else if in_reasons {
159            // 2026-09-26: "Active"/"Not Active" only. Any other spelling is unknown.
160            let flag = match raw {
161                "Active" => Some(true),
162                "Not Active" => Some(false),
163                _ => None,
164            };
165            match key {
166                "SW Power Cap" => active.sw_power_cap = flag,
167                "SW Thermal Slowdown" => active.sw_thermal = flag,
168                "HW Thermal Slowdown" => active.hw_thermal = flag,
169                "HW Power Brake Slowdown" => active.hw_power_brake = flag,
170                _ => {}
171            }
172        }
173    }
174    (counters, active)
175}
176
177/// 2026-09-26: `MemTotal`, `MemAvailable` and `Cached` from `/proc/meminfo`, kB.
178///
179/// GB10 reports its framebuffer usage as `N/A` (`fixtures/gb10_performance.txt`),
180/// so memory is read from here.
181#[derive(Debug, Default, PartialEq)]
182pub struct MemInfo {
183    pub total_kb: Option<u64>,
184    pub available_kb: Option<u64>,
185    pub cached_kb: Option<u64>,
186}
187
188pub fn meminfo(text: &str) -> MemInfo {
189    let mut out = MemInfo::default();
190    for line in text.lines() {
191        let Some((key, raw)) = line.split_once(':') else {
192            continue;
193        };
194        let kb = raw
195            .split_whitespace()
196            .next()
197            .and_then(|n| n.parse::<u64>().ok());
198        match key.trim() {
199            "MemTotal" => out.total_kb = kb,
200            "MemAvailable" => out.available_kb = kb,
201            // 2026-09-26: An exact key, so `SwapCached` never lands here.
202            "Cached" => out.cached_kb = kb,
203            _ => {}
204        }
205    }
206    out
207}
208
209/// 2026-09-26: A `/sys/class/thermal/thermal_zone*/temp` file, milli-degrees
210/// Celsius, as °C; `None` when it is not a finite number.
211pub fn milli_celsius(text: &str) -> Option<f64> {
212    text.trim()
213        .parse::<f64>()
214        .ok()
215        .filter(|m| m.is_finite())
216        .map(|m| m / 1000.0)
217}
218
219/// 2026-09-26: Assemble one zone from its `type` and `temp` file contents.
220///
221/// A zone whose temperature does not parse is dropped: an unreadable zone is not
222/// a 0 °C zone, and the hottest zone feeds the precheck and the equivalence test.
223pub fn thermal_zone(type_text: Option<&str>, temp_text: &str) -> Option<ThermalZone> {
224    Some(ThermalZone {
225        name: type_text.unwrap_or("").trim().to_string(),
226        temp_c: milli_celsius(temp_text)?,
227    })
228}
229
230#[cfg(test)]
231#[path = "parse_tests.rs"]
232mod tests;