metrale_bench/hardware/
energy.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: In-window GPU energy, the pure half: integrate the power readings
4//! that [`super::energy_sampler`] streams and turn a measured window into record keys.
5//!
6//! Owner: bench hardware.
7//! Invariants:
8//! - No I/O: every function works on readings it is handed.
9//! - Every key this module writes carries `gpu_rail` in its name.
10//! - A window with no reading inside it, and a J/token ratio with no tokens or no
11//!   joules, yield `None`: absent, never zero.
12//!
13//! # Store joules and tokens, derive the ratios
14//!
15//! Joules and tokens add across windows; a ratio does not. The record carries
16//! `gpu_rail_energy_j` and the window's token count. It also carries one ratio,
17//! `gpu_rail_joules_per_token`, because a BENCH.toml ceiling can only bound a key
18//! the record carries (the per-rung J/token ceilings in
19//! `kernels/gb10/qwen3.8-27b/BENCH.toml`). Code that re-aggregates windows adds
20//! joules and tokens and divides once, with [`joules_per_token`].
21//!
22//! # Which rail
23//!
24//! The reading is nvidia-smi's `power.draw.average`, the GPU rail only. Measured
25//! 2026-09-20 on GB10, driver 580.126.09: `power.limit`, the module power readings
26//! and GPU memory power answer `N/A`, so the Grace cores and LPDDR5X are outside
27//! this number. `gpu_rail` in every key name keeps a reader from setting it beside
28//! a discrete card's board power.
29//!
30//! # Why not the driver's energy counter
31//!
32//! The telemetry crate reads the driver's cumulative counter
33//! (`nvmlDeviceGetTotalEnergyConsumption`, `crates/telemetry/src/device.rs`).
34//! Switching this reader to it would change what every recorded `gpu_rail_*` key
35//! means under an unchanged name, while the BENCH.toml J/token ceilings bound values
36//! of this integral. Such a switch needs new key names and re-measured ceilings.
37
38use std::collections::BTreeMap;
39use std::time::Instant;
40
41/// 2026-09-26: Sampler cadence (`nvidia-smi -lms`), written beside every run's
42/// joules as `gpu_rail_sample_period_ms`.
43pub const SAMPLE_PERIOD_MS: u64 = 250;
44
45/// 2026-09-26: Length of the idle-baseline window that
46/// [`super::energy_sampler::EnergySampler::idle_baseline`] integrates.
47pub const IDLE_BASELINE_SECS: u64 = 2;
48
49/// 2026-10-04: The J/token record key, after the `c{C}_` rung prefix when there is
50/// one. A BENCH.toml bound on a key ending in it is an energy bound.
51pub const JOULES_PER_TOKEN_KEY: &str = "gpu_rail_joules_per_token";
52
53/// 2026-09-26: Logged once per run when sampling starts (`EnergyMeter::start`).
54pub const RAIL_NOTE: &str = "energy: GPU RAIL ONLY (nvidia-smi power.draw.average, 250 ms cadence). \
55     On GB10 the module rail — Grace cores + LPDDR5X — reads N/A and is NOT in this number; on a \
56     bandwidth-bound unified-memory decode a dominant share of real energy is outside it. Do not \
57     compare to a discrete card's board power.";
58
59/// 2026-09-26: One reading from the sampler.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct PowerSample {
62    pub at: Instant,
63    pub power_w: f64,
64    /// 2026-09-26: `clocks_event_reasons.sw_power_cap`; `None` when the cell was
65    /// neither `Active` nor `Not Active`.
66    pub sw_power_cap: Option<bool>,
67    /// 2026-09-26: `clocks_event_reasons.hw_power_brake_slowdown`, same encoding.
68    pub hw_power_brake: Option<bool>,
69}
70
71/// 2026-09-26: Parse one CSV row of
72/// `--query-gpu=power.draw.average,clocks_event_reasons.sw_power_cap,clocks_event_reasons.hw_power_brake_slowdown`
73/// with `--format=csv,noheader,nounits`, e.g. `4.76, Not Active, Active`.
74///
75/// `None` when the watt cell is not a finite, non-negative number, so a `[N/A]`
76/// rail never integrates as zero watts. A missing or unrecognised flag cell
77/// leaves that flag `None` rather than "not active".
78pub fn parse_line(line: &str, at: Instant) -> Option<PowerSample> {
79    let cells: Vec<&str> = line.split(',').map(str::trim).collect();
80    let power_w = cells
81        .first()?
82        .parse::<f64>()
83        .ok()
84        .filter(|w| w.is_finite() && *w >= 0.0)?;
85    let flag = |i: usize| match cells.get(i).copied() {
86        Some("Active") => Some(true),
87        Some("Not Active") => Some(false),
88        _ => None,
89    };
90    Some(PowerSample {
91        at,
92        power_w,
93        sw_power_cap: flag(1),
94        hw_power_brake: flag(2),
95    })
96}
97
98/// 2026-09-26: The energy integral over one measured window.
99#[derive(Clone, Copy, Debug, Default, PartialEq)]
100pub struct EnergyWindow {
101    /// 2026-09-26: Window length the joules span, seconds.
102    pub window_s: f64,
103    /// 2026-09-26: Readings the integral stood on.
104    pub samples: usize,
105    /// 2026-09-26: `Σ P_i · dt_i`: each reading held forward to the next, the
106    /// first back-filled to the window start, the last held to the window end,
107    /// so `Σ dt_i` is exactly `window_s`.
108    pub energy_j: f64,
109    pub mean_power_w: f64,
110    pub max_power_w: f64,
111    /// 2026-09-26: Of the readings that reported the SW power cap flag, the
112    /// fraction (by count, not time) with it asserted; `None` when none reported it.
113    pub sw_power_cap_frac: Option<f64>,
114    /// 2026-09-26: Same, for the HW power brake.
115    pub hw_power_brake_frac: Option<f64>,
116}
117
118/// 2026-09-26: Integrate the readings inside `[start, end]`, both ends included.
119///
120/// `None` when `end <= start` or no reading falls inside, so a window with no
121/// evidence never reports zero joules.
122pub fn integrate(samples: &[PowerSample], start: Instant, end: Instant) -> Option<EnergyWindow> {
123    if end <= start {
124        return None;
125    }
126    let inside: Vec<&PowerSample> = samples
127        .iter()
128        .filter(|s| s.at >= start && s.at <= end)
129        .collect();
130    if inside.is_empty() {
131        return None;
132    }
133    let window_s = end.duration_since(start).as_secs_f64();
134    let mut energy_j = 0.0;
135    let mut max_power_w = 0.0f64;
136    for (i, s) in inside.iter().enumerate() {
137        let from = if i == 0 { start } else { s.at };
138        let to = inside.get(i + 1).map_or(end, |n| n.at);
139        energy_j += s.power_w * to.duration_since(from).as_secs_f64();
140        max_power_w = max_power_w.max(s.power_w);
141    }
142    let frac = |pick: fn(&PowerSample) -> Option<bool>| {
143        let reported: Vec<bool> = inside.iter().filter_map(|s| pick(s)).collect();
144        (!reported.is_empty())
145            .then(|| reported.iter().filter(|b| **b).count() as f64 / reported.len() as f64)
146    };
147    Some(EnergyWindow {
148        window_s,
149        samples: inside.len(),
150        energy_j,
151        mean_power_w: energy_j / window_s,
152        max_power_w,
153        sw_power_cap_frac: frac(|s| s.sw_power_cap),
154        hw_power_brake_frac: frac(|s| s.hw_power_brake),
155    })
156}
157
158/// 2026-09-26: GPU-rail joules per delivered output token, or `None` when the
159/// pair cannot support a ratio: no tokens, or a joule count that is not a finite
160/// positive number (a delivered token never costs zero energy, so 0 J means the
161/// window measured nothing). `None` rather than 0.0 or ∞ because a ceiling bounds
162/// this value, and any sentinel would read as a pass or as a real reading.
163pub fn joules_per_token(energy_j: f64, tokens: usize) -> Option<f64> {
164    (tokens > 0 && energy_j.is_finite() && energy_j > 0.0).then(|| energy_j / tokens as f64)
165}
166
167impl EnergyWindow {
168    /// 2026-09-26: Joules above what the box would have drawn idle for the same
169    /// duration: `energy_j − idle.mean_power_w × window_s`. Negative when the
170    /// window drew less than the baseline; not clamped.
171    pub fn above_idle_j(&self, idle: &EnergyWindow) -> f64 {
172        self.energy_j - idle.mean_power_w * self.window_s
173    }
174
175    /// 2026-09-26: Several windows as one: joules, seconds and samples add, the
176    /// mean is re-derived, the max is the largest max, and each flag fraction is
177    /// weighted by the sample counts of the windows that report it. `None` for
178    /// no windows.
179    pub fn sum(windows: &[EnergyWindow]) -> Option<EnergyWindow> {
180        if windows.is_empty() {
181            return None;
182        }
183        let window_s: f64 = windows.iter().map(|w| w.window_s).sum();
184        let energy_j: f64 = windows.iter().map(|w| w.energy_j).sum();
185        let samples: usize = windows.iter().map(|w| w.samples).sum();
186        let weighted = |pick: fn(&EnergyWindow) -> Option<f64>| {
187            let (num, den) = windows
188                .iter()
189                .fold((0.0, 0usize), |(n, d), w| match pick(w) {
190                    Some(f) => (n + f * w.samples as f64, d + w.samples),
191                    None => (n, d),
192                });
193            (den > 0).then(|| num / den as f64)
194        };
195        Some(EnergyWindow {
196            window_s,
197            samples,
198            energy_j,
199            mean_power_w: if window_s > 0.0 {
200                energy_j / window_s
201            } else {
202                0.0
203            },
204            max_power_w: windows.iter().map(|w| w.max_power_w).fold(0.0, f64::max),
205            sw_power_cap_frac: weighted(|w| w.sw_power_cap_frac),
206            hw_power_brake_frac: weighted(|w| w.hw_power_brake_frac),
207        })
208    }
209
210    /// 2026-09-26: The record keys for one window, under `prefix` (`"c{C}_"` from
211    /// the concurrency sweep, `""` from decode-floor). `tokens` is the caller's
212    /// count of output tokens delivered inside this window; it is written beside
213    /// the joules, and J/token is derived from the pair.
214    pub fn metrics(
215        &self,
216        prefix: &str,
217        tokens: usize,
218        idle: Option<&EnergyWindow>,
219        m: &mut BTreeMap<String, f64>,
220    ) {
221        let put = |m: &mut BTreeMap<String, f64>, k: &str, v: f64| {
222            m.insert(format!("{prefix}{k}"), v);
223        };
224        put(m, "gpu_rail_energy_j", self.energy_j);
225        put(m, "gpu_rail_energy_window_tokens", tokens as f64);
226        // 2026-09-26: Absent when the window cannot support the ratio, so a J/token
227        // ceiling fails as missing from the record rather than passing.
228        if let Some(r) = joules_per_token(self.energy_j, tokens) {
229            put(m, JOULES_PER_TOKEN_KEY, r);
230        }
231        put(m, "gpu_rail_mean_power_w", self.mean_power_w);
232        put(m, "gpu_rail_max_power_w", self.max_power_w);
233        put(m, "gpu_rail_power_samples", self.samples as f64);
234        put(m, "gpu_rail_energy_window_s", self.window_s);
235        if let Some(f) = self.sw_power_cap_frac {
236            put(m, "gpu_rail_sw_power_cap_frac", f);
237        }
238        if let Some(f) = self.hw_power_brake_frac {
239            put(m, "gpu_rail_hw_power_brake_frac", f);
240        }
241        if let Some(idle) = idle {
242            put(m, "gpu_rail_energy_above_idle_j", self.above_idle_j(idle));
243        }
244    }
245
246    /// 2026-09-26: One log line: joules, seconds, mean and max watts, samples,
247    /// above-idle joules when a baseline is given, the SW cap fraction, and the
248    /// HW brake fraction when it is above zero.
249    pub fn one_line(&self, idle: Option<&EnergyWindow>) -> String {
250        let mut s = format!(
251            "gpu rail {:.1} J over {:.1} s ({:.1} W mean, {:.1} W max, {} samples)",
252            self.energy_j, self.window_s, self.mean_power_w, self.max_power_w, self.samples
253        );
254        if let Some(idle) = idle {
255            s.push_str(&format!(
256                " · {:.1} J above the {:.1} W idle baseline",
257                self.above_idle_j(idle),
258                idle.mean_power_w
259            ));
260        }
261        match self.sw_power_cap_frac {
262            Some(f) => s.push_str(&format!(" · sw power cap {:.0}% of samples", f * 100.0)),
263            None => s.push_str(" · sw power cap unreported"),
264        }
265        if let Some(f) = self.hw_power_brake_frac.filter(|f| *f > 0.0) {
266            s.push_str(&format!(" · HW POWER BRAKE {:.0}% of samples", f * 100.0));
267        }
268        s
269    }
270}
271
272/// 2026-09-26: What the sampler itself cost, written to the record as
273/// `gpu_rail_sample*` keys so the instrument's overhead is visible.
274#[derive(Clone, Copy, Debug, Default, PartialEq)]
275pub struct SamplerCost {
276    /// 2026-09-26: CPU seconds the sampler child consumed, from the first field
277    /// of `/proc/<pid>/schedstat` read as nanoseconds. `None` when procfs could
278    /// not be read.
279    pub cpu_s: Option<f64>,
280    /// 2026-09-26: Wall seconds it ran.
281    pub wall_s: f64,
282    /// 2026-09-26: Readings it produced.
283    pub samples: usize,
284    /// 2026-09-26: Lines it emitted that did not parse as a reading, such as an
285    /// `[N/A]` watt cell. Written to the record when non-zero.
286    pub rejected_lines: u64,
287}
288
289impl SamplerCost {
290    pub fn metrics(&self, m: &mut BTreeMap<String, f64>) {
291        m.insert(
292            "gpu_rail_sample_period_ms".to_string(),
293            SAMPLE_PERIOD_MS as f64,
294        );
295        if let Some(cpu) = self.cpu_s {
296            m.insert("gpu_rail_sampler_cpu_s".to_string(), cpu);
297        }
298        m.insert("gpu_rail_sampler_wall_s".to_string(), self.wall_s);
299        if self.rejected_lines > 0 {
300            m.insert(
301                "gpu_rail_sampler_rejected_lines".to_string(),
302                self.rejected_lines as f64,
303            );
304        }
305    }
306
307    pub fn one_line(&self) -> String {
308        format!(
309            "energy sampler cost: {} CPU over {:.0} s wall ({} samples at {SAMPLE_PERIOD_MS} ms{})",
310            self.cpu_s
311                .map(|c| format!("{c:.3} s"))
312                .unwrap_or_else(|| "unmeasured".into()),
313            self.wall_s,
314            self.samples,
315            if self.rejected_lines > 0 {
316                format!(", {} unparseable lines", self.rejected_lines)
317            } else {
318                String::new()
319            }
320        )
321    }
322}
323
324#[cfg(test)]
325#[path = "energy_tests.rs"]
326mod tests;