metrale_bench/hardware/
energy_sampler.rs1use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::{Duration, Instant};
21
22use anyhow::{Context, Result, bail};
23use parking_lot::Mutex;
24use tokio::io::{AsyncBufReadExt, BufReader};
25
26use super::energy::{
27 EnergyWindow, IDLE_BASELINE_SECS, PowerSample, RAIL_NOTE, SAMPLE_PERIOD_MS, SamplerCost,
28 integrate, parse_line,
29};
30use crate::plugin::TargetEndpoint;
31use crate::result::LogLine;
32
33const QUERY: &str = "--query-gpu=power.draw.average,\
35 clocks_event_reasons.sw_power_cap,\
36 clocks_event_reasons.hw_power_brake_slowdown";
37
38const FIRST_SAMPLE_TIMEOUT: Duration = Duration::from_secs(3);
41
42pub struct EnergySampler {
43 child: tokio::process::Child,
44 pid: u32,
45 started: Instant,
46 samples: Arc<Mutex<Vec<PowerSample>>>,
47 rejected: Arc<AtomicU64>,
48}
49
50impl EnergySampler {
51 pub async fn spawn() -> Result<Self> {
57 let mut child = tokio::process::Command::new("nvidia-smi")
58 .arg(QUERY)
59 .arg("--format=csv,noheader,nounits")
60 .arg("-lms")
61 .arg(SAMPLE_PERIOD_MS.to_string())
62 .stdin(std::process::Stdio::null())
63 .stdout(std::process::Stdio::piped())
64 .stderr(std::process::Stdio::null())
65 .kill_on_drop(true)
66 .spawn()
67 .context("spawning nvidia-smi for power sampling")?;
68 let pid = child
69 .id()
70 .context("nvidia-smi exited before it was polled")?;
71 let stdout = child.stdout.take().context("nvidia-smi stdout not piped")?;
72 let samples = Arc::new(Mutex::new(Vec::new()));
73 let rejected = Arc::new(AtomicU64::new(0));
74 let (sink, counter) = (samples.clone(), rejected.clone());
75 tokio::spawn(async move {
76 let mut lines = BufReader::new(stdout).lines();
77 while let Ok(Some(line)) = lines.next_line().await {
78 let at = Instant::now();
79 match parse_line(&line, at) {
80 Some(s) => sink.lock().push(s),
81 None => {
82 counter.fetch_add(1, Ordering::Relaxed);
83 }
84 }
85 }
86 });
87 let this = Self {
88 child,
89 pid,
90 started: Instant::now(),
91 samples,
92 rejected,
93 };
94 let deadline = Instant::now() + FIRST_SAMPLE_TIMEOUT;
95 while this.samples.lock().is_empty() {
96 if Instant::now() >= deadline {
97 let rejected = this.rejected.load(Ordering::Relaxed);
98 bail!(
99 "nvidia-smi produced no power reading within {FIRST_SAMPLE_TIMEOUT:?} \
100 ({rejected} unparseable line(s)) — the GPU rail is not readable here"
101 );
102 }
103 tokio::time::sleep(Duration::from_millis(20)).await;
104 }
105 Ok(this)
106 }
107
108 pub fn window(&self, start: Instant, end: Instant) -> Option<EnergyWindow> {
110 integrate(&self.samples.lock(), start, end)
111 }
112
113 pub async fn idle_baseline(&self) -> Option<EnergyWindow> {
117 let start = Instant::now();
118 tokio::time::sleep(Duration::from_secs(IDLE_BASELINE_SECS)).await;
119 self.window(start, Instant::now())
120 }
121
122 fn cpu_seconds(&self) -> Option<f64> {
125 std::fs::read_to_string(format!("/proc/{}/schedstat", self.pid))
126 .ok()?
127 .split_whitespace()
128 .next()?
129 .parse::<u64>()
130 .ok()
131 .map(|ns| ns as f64 / 1e9)
132 }
133
134 pub async fn stop(mut self) -> SamplerCost {
136 let cost = SamplerCost {
137 cpu_s: self.cpu_seconds(),
138 wall_s: self.started.elapsed().as_secs_f64(),
139 samples: self.samples.lock().len(),
140 rejected_lines: self.rejected.load(Ordering::Relaxed),
141 };
142 if let Err(e) = self.child.kill().await {
143 tracing::debug!("energy sampler already exited: {e}");
144 }
145 cost
146 }
147}
148
149#[derive(Default)]
157pub struct EnergyMeter {
158 sampler: Option<EnergySampler>,
159 idle: Option<EnergyWindow>,
160 cost: Option<SamplerCost>,
161}
162
163impl EnergyMeter {
164 pub async fn start(&mut self, target: &TargetEndpoint) -> Vec<LogLine> {
168 let mut lines = Vec::new();
169 if !target.is_loopback() {
170 lines.push(LogLine::info(format!(
171 "energy: {} is not loopback — the local GPU rail is not the serving GPU, so \
172 no energy is recorded for this run",
173 target.base_url
174 )));
175 return lines;
176 }
177 match EnergySampler::spawn().await {
178 Ok(sampler) => {
179 lines.push(LogLine::info(RAIL_NOTE));
180 self.idle = sampler.idle_baseline().await;
181 match &self.idle {
182 Some(idle) => lines.push(LogLine::info(format!(
183 "energy: idle baseline (model loaded, nothing in flight) — {}",
184 idle.one_line(None)
185 ))),
186 None => lines.push(LogLine::warn(
187 "energy: the idle baseline window produced no samples; totals will be \
188 recorded without an above-idle figure",
189 )),
190 }
191 self.sampler = Some(sampler);
192 }
193 Err(e) => lines.push(LogLine::warn(format!("energy: not recorded — {e:#}"))),
194 }
195 lines
196 }
197
198 pub fn window(&self, start: Instant, end: Instant) -> Option<EnergyWindow> {
200 self.sampler.as_ref()?.window(start, end)
201 }
202
203 pub fn idle(&self) -> Option<&EnergyWindow> {
204 self.idle.as_ref()
205 }
206
207 pub async fn stop(&mut self) -> Option<LogLine> {
210 let cost = self.sampler.take()?.stop().await;
211 let line = LogLine::info(cost.one_line());
212 self.cost = Some(cost);
213 Some(line)
214 }
215
216 pub fn metrics(&self, m: &mut std::collections::BTreeMap<String, f64>) {
219 if let Some(idle) = &self.idle {
220 m.insert("gpu_rail_idle_power_w".to_string(), idle.mean_power_w);
221 m.insert(
222 "gpu_rail_idle_power_samples".to_string(),
223 idle.samples as f64,
224 );
225 }
226 if let Some(cost) = &self.cost {
227 cost.metrics(m);
228 }
229 }
230}