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;