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 }