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;