git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/power_meter.rs (25.1K)

  1 //! Per-process power estimation for the Processes page.
  2 //!
  3 //! Nothing on a laptop reports watts per process, so this is an attribution
  4 //! model, not a measurement. What IS measured, per sample:
  5 //!
  6 //! - the whole-machine draw: `BAT0/power_now` while discharging, or the
  7 //!   RAPL `psys` domain when its counter is readable (it is root-only by
  8 //!   kernel default since the PLATYPUS fix, so on AC it usually is not);
  9 //! - per-domain CPU energy from RAPL `core` / `uncore` (uncore ≈ the iGPU on
 10 //!   Intel client parts), again only when readable;
 11 //! - the discrete GPU's draw from nvidia-smi, only while the card is awake;
 12 //! - per process: CPU ticks from `/proc/<pid>/stat`, iGPU engine busy time
 13 //!   from the i915 `fdinfo` of every `/dev/dri` fd it holds, context switches
 14 //!   from `status` (wakeups/s, powertop's idle metric), and whether it holds a
 15 //!   `/dev/nvidia*` fd.
 16 //!
 17 //! The split then works on *deltas* between two snapshots. Each measured
 18 //! source is reduced by its own rolling minimum over the last minute — the
 19 //! floor is the screen, radios and idle silicon, which no process owns — and
 20 //! only the part above the floor is handed out, in proportion to each pid's
 21 //! share of the activity that source responds to. Battery-only mode has one
 22 //! budget (total minus floor, minus whatever the dGPU took) split by CPU time
 23 //! with iGPU time folded in; RAPL mode has a budget per domain. Per-pid
 24 //! results are smoothed with a ~10s exponential average so the list does not
 25 //! reorder on every tick.
 26 //!
 27 //! Everything below [`Meter`] is pure and unit-tested on synthetic snapshots;
 28 //! [`sample`] is the only thing that touches the machine.
 29 
 30 use std::collections::{HashMap, VecDeque};
 31 use std::sync::OnceLock;
 32 use std::time::Instant;
 33 
 34 /// `/proc` CPU times are in USER_HZ ticks, which Linux fixes at 100 on every
 35 /// architecture whatever the kernel's own HZ is.
 36 pub const USER_HZ: f64 = 100.0;
 37 /// One nanosecond of iGPU engine time counted against one nanosecond of CPU
 38 /// core time in the battery-only split. A busy render engine and a busy core
 39 /// land in the same handful of watts on this class of part, so 1:1 is as
 40 /// honest as any other single figure; RAPL mode does not use it.
 41 pub const GPU_NS_WEIGHT: f64 = 1.0;
 42 /// How far back the rolling-minimum floor looks.
 43 pub const FLOOR_WINDOW_SECS: f64 = 60.0;
 44 /// Time constant of the per-pid smoothing.
 45 pub const SMOOTH_TAU_SECS: f64 = 10.0;
 46 
 47 #[derive(Debug, Clone, Default, PartialEq, Eq)]
 48 pub struct ProcSample {
 49     /// utime + stime, USER_HZ ticks.
 50     pub cpu_ticks: u64,
 51     /// Summed i915 engine busy time across the pid's DRM clients.
 52     pub gpu_ns: u64,
 53     /// voluntary + nonvoluntary context switches.
 54     pub ctxt_switches: u64,
 55     /// Holds an open `/dev/nvidia*` fd.
 56     pub dgpu: bool,
 57 }
 58 
 59 #[derive(Debug, Clone, Default, PartialEq, Eq)]
 60 pub struct RaplDomain {
 61     /// sysfs `name`: `package-0`, `core`, `uncore`, `psys`, …
 62     pub name: String,
 63     pub energy_uj: u64,
 64     /// `max_energy_range_uj` — the counter wraps at this.
 65     pub range_uj: u64,
 66 }
 67 
 68 #[derive(Debug, Clone, Default)]
 69 pub struct Snapshot {
 70     /// Seconds on an arbitrary monotonic clock.
 71     pub t: f64,
 72     /// Battery draw, only while discharging (the field is meaningless
 73     /// otherwise, so the sampler leaves it None).
 74     pub battery_w: Option<f64>,
 75     pub rapl: Vec<RaplDomain>,
 76     /// nvidia-smi power.draw, None while the card is runtime-suspended or
 77     /// there is no driver.
 78     pub dgpu_w: Option<f64>,
 79     pub procs: HashMap<u32, ProcSample>,
 80 }
 81 
 82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 83 pub enum Mode {
 84     /// One snapshot so far, or a zero-length interval: nothing to attribute yet.
 85     Warming,
 86     /// RAPL core/uncore readable: CPU and iGPU budgets are measured per domain.
 87     Rapl,
 88     /// Only the battery's total is known: one budget, split by activity.
 89     Battery,
 90     /// On AC without RAPL access, or no battery: wakeups only.
 91     Unavailable,
 92 }
 93 
 94 #[derive(Debug, Clone, Copy, PartialEq)]
 95 pub struct PidPower {
 96     /// Smoothed estimate; None when the mode cannot produce one.
 97     pub watts: Option<f32>,
 98     pub wakeups_per_s: f32,
 99 }
100 
101 #[derive(Debug, Clone, PartialEq)]
102 pub struct Attribution {
103     pub mode: Mode,
104     /// Whole-machine draw this interval, when any source reports it.
105     pub total_w: Option<f32>,
106     /// Rolling-minimum of `total_w`: the unattributable baseline.
107     pub floor_w: Option<f32>,
108     /// Sum of every pid's smoothed estimate.
109     pub attributed_w: f32,
110     pub per_pid: HashMap<u32, PidPower>,
111 }
112 
113 impl Attribution {
114     fn empty(mode: Mode) -> Self {
115         Self { mode, total_w: None, floor_w: None, attributed_w: 0.0, per_pid: HashMap::new() }
116     }
117 }
118 
119 /// Rolling minimum over a time window.
120 #[derive(Debug, Default)]
121 struct Floor {
122     samples: VecDeque<(f64, f64)>,
123 }
124 
125 impl Floor {
126     /// Record `w` at time `t`; return the minimum over the window ending now.
127     fn push(&mut self, t: f64, w: f64) -> f64 {
128         self.samples.push_back((t, w));
129         while let Some(&(t0, _)) = self.samples.front() {
130             if t - t0 > FLOOR_WINDOW_SECS {
131                 self.samples.pop_front();
132             } else {
133                 break;
134             }
135         }
136         self.samples.iter().map(|&(_, w)| w).fold(f64::INFINITY, f64::min)
137     }
138 }
139 
140 /// Holds the previous snapshot, the floors and the smoothing state between
141 /// ticks. One per page; the fetch keeps it in a static.
142 #[derive(Debug, Default)]
143 pub struct Meter {
144     prev: Option<Snapshot>,
145     floors: HashMap<&'static str, Floor>,
146     ema: HashMap<u32, f64>,
147 }
148 
149 /// Counter delta with wrap-around at `range` (0 = no wrap known).
150 fn wrapped_delta(prev: u64, next: u64, range: u64) -> u64 {
151     if next >= prev {
152         next - prev
153     } else if range > 0 {
154         range - prev + next
155     } else {
156         0
157     }
158 }
159 
160 impl Meter {
161     pub fn new() -> Self {
162         Self::default()
163     }
164 
165     fn floor(&mut self, key: &'static str, t: f64, w: f64) -> f64 {
166         self.floors.entry(key).or_default().push(t, w)
167     }
168 
169     /// Budget a source hands out: its reading above its own rolling minimum.
170     fn budget(&mut self, key: &'static str, t: f64, w: Option<f64>) -> f64 {
171         match w {
172             Some(w) => (w - self.floor(key, t, w)).max(0.0),
173             None => 0.0,
174         }
175     }
176 
177     pub fn tick(&mut self, snap: Snapshot) -> Attribution {
178         let Some(prev) = self.prev.replace(snap) else {
179             // Instantaneous sources seed their floors now, so the next
180             // tick's reading is measured against this one rather than
181             // against itself. RAPL is a counter and needs the interval.
182             let s = self.prev.as_ref().unwrap();
183             let (t, bat, dgpu) = (s.t, s.battery_w, s.dgpu_w);
184             if let Some(w) = bat {
185                 self.floor("total", t, w);
186             }
187             if let Some(w) = dgpu {
188                 self.floor("dgpu", t, w);
189             }
190             return Attribution::empty(Mode::Warming);
191         };
192         let snap = self.prev.as_ref().unwrap();
193         let dt = snap.t - prev.t;
194         if dt <= 0.0 {
195             return Attribution::empty(Mode::Warming);
196         }
197 
198         // RAPL watts per domain over the interval.
199         let rapl_w = |name: &str| -> Option<f64> {
200             let a = prev.rapl.iter().find(|d| d.name == name)?;
201             let b = snap.rapl.iter().find(|d| d.name == name)?;
202             Some(wrapped_delta(a.energy_uj, b.energy_uj, b.range_uj) as f64 / 1e6 / dt)
203         };
204         let core = rapl_w("core");
205         let uncore = rapl_w("uncore");
206         let psys = rapl_w("psys");
207         let package = snap
208             .rapl
209             .iter()
210             .find(|d| d.name.starts_with("package"))
211             .and_then(|d| rapl_w(&d.name));
212 
213         let total = snap.battery_w.or(psys).or(package);
214         let mode = if core.is_some() {
215             Mode::Rapl
216         } else if snap.battery_w.is_some() {
217             Mode::Battery
218         } else {
219             Mode::Unavailable
220         };
221 
222         // Per-pid activity over the interval, pids present in both snapshots.
223         struct Delta {
224             cpu_ns: f64,
225             gpu_ns: f64,
226             wakeups: f64,
227             dgpu: bool,
228         }
229         let deltas: HashMap<u32, Delta> = snap
230             .procs
231             .iter()
232             .filter_map(|(pid, b)| {
233                 let a = prev.procs.get(pid)?;
234                 Some((
235                     *pid,
236                     Delta {
237                         cpu_ns: b.cpu_ticks.saturating_sub(a.cpu_ticks) as f64 * 1e9 / USER_HZ,
238                         gpu_ns: b.gpu_ns.saturating_sub(a.gpu_ns) as f64,
239                         wakeups: b.ctxt_switches.saturating_sub(a.ctxt_switches) as f64 / dt,
240                         dgpu: b.dgpu,
241                     },
242                 ))
243             })
244             .collect();
245 
246         let t = snap.t;
247         let snap_dgpu = snap.dgpu_w;
248         let floor = total.map(|w| self.floor("total", t, w));
249         let dgpu_budget = self.budget("dgpu", t, snap_dgpu);
250         let dgpu_holders = deltas.values().filter(|d| d.dgpu).count() as f64;
251 
252         // Raw per-pid watts for this interval.
253         let mut raw: HashMap<u32, f64> = HashMap::new();
254         let share = |get: &dyn Fn(&Delta) -> f64| -> HashMap<u32, f64> {
255             let sum: f64 = deltas.values().map(|d| get(d)).sum();
256             if sum <= 0.0 {
257                 return HashMap::new();
258             }
259             deltas.iter().map(|(pid, d)| (*pid, get(d) / sum)).collect()
260         };
261         match mode {
262             Mode::Rapl => {
263                 let cpu_budget = self.budget("core", t, core);
264                 let gpu_budget = self.budget("uncore", t, uncore);
265                 for (pid, s) in share(&|d| d.cpu_ns) {
266                     *raw.entry(pid).or_default() += s * cpu_budget;
267                 }
268                 for (pid, s) in share(&|d| d.gpu_ns) {
269                     *raw.entry(pid).or_default() += s * gpu_budget;
270                 }
271             }
272             Mode::Battery => {
273                 let dynamic = (total.unwrap() - floor.unwrap()).max(0.0);
274                 let budget = (dynamic - dgpu_budget).max(0.0);
275                 for (pid, s) in share(&|d| d.cpu_ns + GPU_NS_WEIGHT * d.gpu_ns) {
276                     *raw.entry(pid).or_default() += s * budget;
277                 }
278             }
279             Mode::Warming | Mode::Unavailable => {}
280         }
281         if mode != Mode::Unavailable && dgpu_holders > 0.0 {
282             for (pid, d) in &deltas {
283                 if d.dgpu {
284                     *raw.entry(*pid).or_default() += dgpu_budget / dgpu_holders;
285                 }
286             }
287         }
288 
289         // Smooth, and forget pids that are gone.
290         let alpha = 1.0 - (-dt / SMOOTH_TAU_SECS).exp();
291         self.ema.retain(|pid, _| deltas.contains_key(pid));
292         let mut per_pid = HashMap::with_capacity(deltas.len());
293         let mut attributed = 0.0;
294         for (pid, d) in &deltas {
295             let watts = if mode == Mode::Unavailable {
296                 None
297             } else {
298                 let r = raw.get(pid).copied().unwrap_or(0.0);
299                 let e = match self.ema.get(pid) {
300                     Some(&e) => e + alpha * (r - e),
301                     None => r,
302                 };
303                 self.ema.insert(*pid, e);
304                 attributed += e;
305                 Some(e as f32)
306             };
307             per_pid.insert(*pid, PidPower { watts, wakeups_per_s: d.wakeups as f32 });
308         }
309 
310         Attribution {
311             mode,
312             total_w: total.map(|w| w as f32),
313             floor_w: floor.map(|w| w as f32),
314             attributed_w: attributed as f32,
315             per_pid,
316         }
317     }
318 }
319 
320 // ── The machine-facing half ──
321 
322 fn read_trim(path: &std::path::Path) -> Option<String> {
323     std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
324 }
325 
326 fn clock_secs() -> f64 {
327     static START: OnceLock<Instant> = OnceLock::new();
328     START.get_or_init(Instant::now).elapsed().as_secs_f64()
329 }
330 
331 /// utime + stime from a `/proc/<pid>/stat` line. The comm field can contain
332 /// spaces and parens, so fields are counted from the LAST `)`.
333 pub fn parse_stat_ticks(stat: &str) -> Option<u64> {
334     let rest = &stat[stat.rfind(')')? + 1..];
335     let f: Vec<&str> = rest.split_whitespace().collect();
336     // After ')': state ppid pgrp session tty tpgid flags minflt cminflt
337     // majflt cmajflt utime stime …
338     Some(f.get(11)?.parse::<u64>().ok()? + f.get(12)?.parse::<u64>().ok()?)
339 }
340 
341 /// voluntary + nonvoluntary context switches from `/proc/<pid>/status`.
342 pub fn parse_status_switches(status: &str) -> u64 {
343     status
344         .lines()
345         .filter_map(|l| {
346             let (k, v) = l.split_once(':')?;
347             if k.ends_with("ctxt_switches") { v.trim().parse::<u64>().ok() } else { None }
348         })
349         .sum()
350 }
351 
352 /// (client id, summed engine busy ns) from one DRM fd's fdinfo. Capacity
353 /// lines are counts, not times, and are skipped.
354 pub fn parse_fdinfo_engines(fdinfo: &str) -> Option<(u64, u64)> {
355     let mut client = None;
356     let mut ns = 0u64;
357     for l in fdinfo.lines() {
358         let Some((k, v)) = l.split_once(':') else { continue };
359         let v = v.trim();
360         if k == "drm-client-id" {
361             client = v.parse().ok();
362         } else if k.starts_with("drm-engine-") && !k.starts_with("drm-engine-capacity") {
363             ns += v.split_whitespace().next().and_then(|n| n.parse::<u64>().ok()).unwrap_or(0);
364         }
365     }
366     client.map(|c| (c, ns))
367 }
368 
369 fn sample_proc(dir: &std::path::Path) -> Option<ProcSample> {
370     let cpu_ticks = parse_stat_ticks(&std::fs::read_to_string(dir.join("stat")).ok()?)?;
371     let ctxt_switches =
372         std::fs::read_to_string(dir.join("status")).map(|s| parse_status_switches(&s)).unwrap_or(0);
373 
374     // fd links are only readable for our own processes; others simply
375     // contribute no GPU time.
376     let mut clients: HashMap<u64, u64> = HashMap::new();
377     let mut dgpu = false;
378     if let Ok(fds) = std::fs::read_dir(dir.join("fd")) {
379         for fd in fds.flatten() {
380             let Ok(target) = std::fs::read_link(fd.path()) else { continue };
381             let Some(target) = target.to_str() else { continue };
382             if target.starts_with("/dev/nvidia") {
383                 dgpu = true;
384             } else if target.starts_with("/dev/dri/") {
385                 let info = dir.join("fdinfo").join(fd.file_name());
386                 if let Some((client, ns)) =
387                     std::fs::read_to_string(info).ok().and_then(|s| parse_fdinfo_engines(&s))
388                 {
389                     // Several fds can share one client; count it once.
390                     clients.insert(client, ns);
391                 }
392             }
393         }
394     }
395     Some(ProcSample { cpu_ticks, gpu_ns: clients.values().sum(), ctxt_switches, dgpu })
396 }
397 
398 fn sample_rapl() -> Vec<RaplDomain> {
399     let mut out = Vec::new();
400     let Ok(entries) = std::fs::read_dir("/sys/class/powercap") else { return out };
401     for e in entries.flatten() {
402         let p = e.path();
403         if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("intel-rapl:")) {
404             continue;
405         }
406         let (Some(name), Some(energy)) = (read_trim(&p.join("name")), read_trim(&p.join("energy_uj")))
407         else {
408             continue;
409         };
410         let Ok(energy_uj) = energy.parse() else { continue };
411         let range_uj = read_trim(&p.join("max_energy_range_uj")).and_then(|s| s.parse().ok()).unwrap_or(0);
412         out.push(RaplDomain { name, energy_uj, range_uj });
413     }
414     out
415 }
416 
417 fn sample_battery_w() -> Option<f64> {
418     let dir = crate::pages::power::battery_dir()?;
419     if read_trim(&dir.join("status"))? != "Discharging" {
420         return None;
421     }
422     read_trim(&dir.join("power_now"))?.parse::<f64>().ok().map(|uw| uw / 1e6)
423 }
424 
425 /// Whether the NVIDIA card is awake. Polling nvidia-smi wakes a suspended
426 /// card — a few watts, every tick, for a number that would be zero — so the
427 /// caller only queries the draw when this says active.
428 pub fn dgpu_awake() -> bool {
429     let Ok(devs) = std::fs::read_dir("/sys/bus/pci/drivers/nvidia") else { return false };
430     devs.flatten()
431         .any(|d| read_trim(&d.path().join("power/runtime_status")).as_deref() == Some("active"))
432 }
433 
434 /// One pass over the machine. `dgpu_w` is passed in because it comes from an
435 /// async nvidia-smi call the caller owns.
436 pub fn sample(dgpu_w: Option<f64>) -> Snapshot {
437     let mut procs = HashMap::new();
438     if let Ok(entries) = std::fs::read_dir("/proc") {
439         for e in entries.flatten() {
440             let Some(pid) = e.file_name().to_str().and_then(|n| n.parse::<u32>().ok()) else { continue };
441             if let Some(s) = sample_proc(&e.path()) {
442                 procs.insert(pid, s);
443             }
444         }
445     }
446     Snapshot { t: clock_secs(), battery_w: sample_battery_w(), rapl: sample_rapl(), dgpu_w, procs }
447 }
448 
449 #[cfg(test)]
450 mod tests {
451     use super::*;
452 
453     fn proc(cpu_ticks: u64, gpu_ns: u64, ctxt: u64) -> ProcSample {
454         ProcSample { cpu_ticks, gpu_ns, ctxt_switches: ctxt, dgpu: false }
455     }
456 
457     fn snap(t: f64, battery_w: Option<f64>, procs: &[(u32, ProcSample)]) -> Snapshot {
458         Snapshot { t, battery_w, rapl: Vec::new(), dgpu_w: None, procs: procs.iter().cloned().collect() }
459     }
460 
461     fn w(a: &Attribution, pid: u32) -> f32 {
462         a.per_pid[&pid].watts.unwrap()
463     }
464 
465     #[test]
466     fn first_tick_is_warming() {
467         let mut m = Meter::new();
468         let a = m.tick(snap(0.0, Some(20.0), &[(1, proc(0, 0, 0))]));
469         assert_eq!(a.mode, Mode::Warming);
470         assert!(a.per_pid.is_empty());
471     }
472 
473     #[test]
474     fn battery_mode_splits_dynamic_by_cpu_share() {
475         let mut m = Meter::new();
476         m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
477         // 10 W floor (the minimum seen), 16 W now: 6 W to split 3:1.
478         let a = m.tick(snap(3.0, Some(16.0), &[(1, proc(300, 0, 0)), (2, proc(100, 0, 0))]));
479         assert_eq!(a.mode, Mode::Battery);
480         assert_eq!(a.total_w, Some(16.0));
481         assert_eq!(a.floor_w, Some(10.0));
482         assert!((w(&a, 1) - 4.5).abs() < 1e-4);
483         assert!((w(&a, 2) - 1.5).abs() < 1e-4);
484         assert!((a.attributed_w - 6.0).abs() < 1e-4);
485     }
486 
487     #[test]
488     fn igpu_time_counts_in_battery_mode() {
489         let mut m = Meter::new();
490         m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
491         // pid 1: 1 s of CPU (100 ticks). pid 2: 1 s of render engine. Equal.
492         let a = m.tick(snap(1.0, Some(12.0), &[(1, proc(100, 0, 0)), (2, proc(0, 1_000_000_000, 0))]));
493         assert!((w(&a, 1) - w(&a, 2)).abs() < 1e-4);
494     }
495 
496     #[test]
497     fn floor_is_the_rolling_minimum_and_never_goes_negative() {
498         let mut m = Meter::new();
499         m.tick(snap(0.0, Some(15.0), &[(1, proc(0, 0, 0))]));
500         let a = m.tick(snap(3.0, Some(12.0), &[(1, proc(50, 0, 0))]));
501         // The new low IS the floor: nothing above it to attribute.
502         assert_eq!(a.floor_w, Some(12.0));
503         assert_eq!(w(&a, 1), 0.0);
504         // 59 s after the 12 W low it is still in the window: 14 W now is
505         // 2 W above it, and that goes to the one busy pid.
506         let a = m.tick(snap(62.0, Some(14.0), &[(1, proc(100, 0, 0))]));
507         assert_eq!(a.floor_w, Some(12.0));
508         assert!(w(&a, 1) > 0.0);
509         // Once the low has aged out, the floor rises to what is left.
510         let a = m.tick(snap(70.0, Some(14.0), &[(1, proc(200, 0, 0))]));
511         assert_eq!(a.floor_w, Some(14.0));
512     }
513 
514     #[test]
515     fn wakeups_are_switches_per_second_even_when_unavailable() {
516         let mut m = Meter::new();
517         m.tick(snap(0.0, None, &[(1, proc(0, 0, 100))]));
518         let a = m.tick(snap(2.0, None, &[(1, proc(0, 0, 160))]));
519         assert_eq!(a.mode, Mode::Unavailable);
520         assert_eq!(a.per_pid[&1].watts, None);
521         assert_eq!(a.per_pid[&1].wakeups_per_s, 30.0);
522     }
523 
524     #[test]
525     fn exited_pids_drop_out_and_new_ones_wait_a_tick() {
526         let mut m = Meter::new();
527         m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
528         m.tick(snap(3.0, Some(12.0), &[(1, proc(100, 0, 0)), (2, proc(100, 0, 0))]));
529         let a = m.tick(snap(6.0, Some(12.0), &[(1, proc(200, 0, 0)), (3, proc(5, 0, 0))]));
530         assert!(!a.per_pid.contains_key(&2));
531         assert!(!a.per_pid.contains_key(&3), "no previous sample to diff against");
532         assert!(!m.ema.contains_key(&2));
533     }
534 
535     #[test]
536     fn smoothing_moves_toward_the_new_value_not_onto_it() {
537         let mut m = Meter::new();
538         m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0))]));
539         let a = m.tick(snap(3.0, Some(16.0), &[(1, proc(300, 0, 0))]));
540         assert!((w(&a, 1) - 6.0).abs() < 1e-4, "first estimate is taken as-is");
541         // Activity stops: raw drops to 0, the average decays toward it.
542         let a = m.tick(snap(6.0, Some(10.0), &[(1, proc(300, 0, 0))]));
543         let v = w(&a, 1);
544         assert!(v > 0.0 && v < 6.0, "{v}");
545     }
546 
547     fn rapl(name: &str, uj: u64, range: u64) -> RaplDomain {
548         RaplDomain { name: name.to_string(), energy_uj: uj, range_uj: range }
549     }
550 
551     #[test]
552     fn rapl_mode_budgets_core_by_cpu_and_uncore_by_gpu() {
553         let mut m = Meter::new();
554         let mut s0 = snap(0.0, None, &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]);
555         s0.rapl = vec![rapl("package-0", 0, 0), rapl("core", 0, 0), rapl("uncore", 0, 0)];
556         m.tick(s0);
557         // Interval 1 establishes the floors (idle: 1 W core, 0.5 W uncore).
558         let mut s1 = snap(1.0, None, &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]);
559         s1.rapl = vec![rapl("package-0", 2_000_000, 0), rapl("core", 1_000_000, 0), rapl("uncore", 500_000, 0)];
560         m.tick(s1);
561         // Interval 2: core 5 W (4 above floor), uncore 2.5 W (2 above); pid 1
562         // did all the CPU work, pid 2 all the GPU work.
563         let mut s2 = snap(2.0, None, &[(1, proc(100, 0, 0)), (2, proc(0, 1_000_000_000, 0))]);
564         s2.rapl = vec![rapl("package-0", 10_000_000, 0), rapl("core", 6_000_000, 0), rapl("uncore", 3_000_000, 0)];
565         let a = m.tick(s2);
566         assert_eq!(a.mode, Mode::Rapl);
567         // Both pids already had a (zero) estimate from interval 1, so the
568         // 4 W and 2 W raw figures arrive through the smoothing step.
569         let alpha = 1.0 - (-1.0f64 / SMOOTH_TAU_SECS).exp();
570         assert!((w(&a, 1) as f64 - alpha * 4.0).abs() < 1e-4, "{}", w(&a, 1));
571         assert!((w(&a, 2) as f64 - alpha * 2.0).abs() < 1e-4, "{}", w(&a, 2));
572         // Total falls back to the package domain when neither battery nor psys report.
573         assert_eq!(a.total_w, Some(8.0));
574     }
575 
576     #[test]
577     fn rapl_counter_wraps() {
578         assert_eq!(wrapped_delta(900, 100, 1000), 200);
579         assert_eq!(wrapped_delta(100, 900, 1000), 800);
580         assert_eq!(wrapped_delta(900, 100, 0), 0, "unknown range: no wrap guess");
581     }
582 
583     #[test]
584     fn dgpu_budget_is_split_equally_among_fd_holders() {
585         let mut m = Meter::new();
586         let mut s0 = snap(0.0, Some(20.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0)), (3, proc(0, 0, 0))]);
587         s0.dgpu_w = Some(4.0);
588         m.tick(s0);
589         let hold = |ticks| ProcSample { cpu_ticks: ticks, gpu_ns: 0, ctxt_switches: 0, dgpu: true };
590         // Total +10 W, of which the dGPU rose 6 W: that 6 goes 3+3 to the two
591         // holders, and the remaining 4 follows CPU time (all pid 3's).
592         let mut s1 = snap(3.0, Some(30.0), &[(1, hold(0)), (2, hold(0)), (3, proc(100, 0, 0))]);
593         s1.dgpu_w = Some(10.0);
594         let a = m.tick(s1);
595         assert!((w(&a, 1) - 3.0).abs() < 1e-4);
596         assert!((w(&a, 2) - 3.0).abs() < 1e-4);
597         assert!((w(&a, 3) - 4.0).abs() < 1e-4);
598     }
599 
600     /// Real-machine smoke: `cargo test -p cce-system-interface --lib
601     /// live_sample -- --ignored --nocapture`. Prints the sampler's cost, the
602     /// mode this host lands in, and the top estimates.
603     #[test]
604     #[ignore]
605     fn live_sample() {
606         let mut m = Meter::new();
607         let t0 = Instant::now();
608         let s0 = sample(None);
609         let cost = t0.elapsed();
610         eprintln!("sample: {:?} for {} pids, battery={:?}, rapl={:?}", cost, s0.procs.len(), s0.battery_w,
611             s0.rapl.iter().map(|d| d.name.as_str()).collect::<Vec<_>>());
612         m.tick(s0);
613         std::thread::sleep(std::time::Duration::from_secs(3));
614         let a = m.tick(sample(None));
615         eprintln!("mode={:?} total={:?} floor={:?} attributed={}", a.mode, a.total_w, a.floor_w, a.attributed_w);
616         let mut rows: Vec<_> = a.per_pid.iter().collect();
617         rows.sort_by(|x, y| y.1.watts.partial_cmp(&x.1.watts).unwrap());
618         for (pid, p) in rows.iter().take(8) {
619             eprintln!("  {pid}: {:?} W, {:.0} wake/s", p.watts, p.wakeups_per_s);
620         }
621     }
622 
623     #[test]
624     fn stat_ticks_survive_spaces_and_parens_in_comm() {
625         let line = "42 (Web Content (x)) S 1 42 42 0 -1 4194560 100 0 0 0 250 75 0 0 20 0 1 0 12345 0 0";
626         assert_eq!(parse_stat_ticks(line), Some(325));
627         assert_eq!(parse_stat_ticks("garbage"), None);
628     }
629 
630     #[test]
631     fn status_switches_sum_both_kinds() {
632         let s = "Name:\tx\nvoluntary_ctxt_switches:\t120\nnonvoluntary_ctxt_switches:\t5\n";
633         assert_eq!(parse_status_switches(s), 125);
634     }
635 
636     #[test]
637     fn fdinfo_engines_sum_time_and_skip_capacity() {
638         let s = "drm-driver:\ti915\ndrm-client-id:\t7\ndrm-engine-render:\t1000 ns\ndrm-engine-copy:\t10 ns\ndrm-engine-capacity-video:\t2\ndrm-engine-video:\t5 ns\n";
639         assert_eq!(parse_fdinfo_engines(s), Some((7, 1015)));
640         assert_eq!(parse_fdinfo_engines("drm-driver:\ti915\n"), None);
641     }
642 }