system settings
git clone https://git.lucas.co/cce-system-interface.git
processes: estimated watts and wakeups per process
Nothing on the host reports watts per process, so this is attribution,
not measurement. power_meter samples the battery draw (discharging only),
the RAPL domains when their counters are readable, the NVIDIA draw only
while the card is awake — nvidia-smi would wake a suspended card to report
a zero — and per pid: CPU ticks, i915 engine time from fdinfo (deduped by
drm-client-id), context switches, and whether it holds a /dev/nvidia fd.
Each source's reading above its own 60s rolling minimum is the budget it
hands out — the floor is screen, radios and idle silicon, owned by no
process — split by each pid's share of the activity that source responds
to: one budget by CPU+iGPU time in battery-only mode, a budget per RAPL
domain when core/uncore are readable. Per-pid figures are smoothed over
~10s. The page shows a W column and a WAKE/s column, both sortable, and a
line above the list stating total, baseline, attributed, and how.
Battery-only is what this host lands in; RAPL needs energy_uj opened to
0444 (root-only by kernel default), left as an opt-in tmpfiles rule.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/lib.rs | 1 +
src/pages/power.rs | 2 +-
src/pages/processes.rs | 215 ++++++++++++++++-
src/power_meter.rs | 642 +++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 849 insertions(+), 11 deletions(-)
diff --git a/src/lib.rs b/src/lib.rs
index 1e188a8..7fa8e5c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
pub mod app;
pub mod pages;
+pub mod power_meter;
pub use cce_ui::widget::scroll_region;
pub mod widgets;
pub mod watchers;
diff --git a/src/pages/power.rs b/src/pages/power.rs
index fd4d2ee..e0b51e3 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -216,7 +216,7 @@ fn drm_card_with_freq() -> Option<std::path::PathBuf> {
}
/// The first battery under /sys/class/power_supply, by convention BAT*.
-fn battery_dir() -> Option<std::path::PathBuf> {
+pub(crate) fn battery_dir() -> Option<std::path::PathBuf> {
let rd = std::fs::read_dir("/sys/class/power_supply").ok()?;
let mut bats: Vec<_> = rd
.flatten()
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 0a4a6a2..8ae1b8d 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -1,6 +1,8 @@
use crate::app::{AppAction, PageContent};
+use crate::power_meter::{self, Meter, Mode};
use cce_ui::widget::ScrollRegion;
use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
+use std::sync::Mutex;
#[derive(Debug, Clone)]
pub struct ProcessRow {
@@ -9,8 +11,33 @@ pub struct ProcessRow {
pub mem_pct: String,
pub rss_kb: u64,
pub command: String,
+ /// Estimated draw from [`power_meter`]; None until the meter has two
+ /// samples of this pid, or when nothing on the host reports watts.
+ pub watts: Option<f32>,
+ /// Context switches per second over the last interval — the honest
+ /// signal for a process that burns power while looking idle.
+ pub wakeups: Option<f32>,
}
+/// The whole-machine side of the estimate, for the line above the list.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PowerSummary {
+ pub mode: Mode,
+ pub total_w: Option<f32>,
+ pub floor_w: Option<f32>,
+ pub attributed_w: f32,
+}
+
+impl Default for PowerSummary {
+ fn default() -> Self {
+ Self { mode: Mode::Warming, total_w: None, floor_w: None, attributed_w: 0.0 }
+ }
+}
+
+/// One meter for the page's lifetime: attribution is a delta between
+/// consecutive fetches, so the previous snapshot has to outlive the fetch.
+static METER: Mutex<Option<Meter>> = Mutex::new(None);
+
/// Which column orders the list. Cpu is the default (ps sorts the fetch);
/// Mem is the toggle — clicking a memory header switches to it, clicking
/// again switches back.
@@ -19,6 +46,8 @@ pub enum ProcSort {
#[default]
Cpu,
Mem,
+ Power,
+ Wakeups,
}
#[derive(Debug, Clone)]
@@ -33,6 +62,7 @@ pub struct ProcessesState {
/// Active sort column. Survives refreshes: Refreshed re-sorts the fresh
/// list under this key rather than resetting to the fetch order.
pub sort: ProcSort,
+ pub power: PowerSummary,
}
impl Default for ProcessesState {
@@ -43,6 +73,7 @@ impl Default for ProcessesState {
cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
killing: std::collections::HashSet::new(),
sort: ProcSort::Cpu,
+ power: PowerSummary::default(),
}
}
}
@@ -68,9 +99,72 @@ fn sort_rows(rows: &mut [ProcessRow], sort: ProcSort) {
bv.partial_cmp(&av).unwrap_or(std::cmp::Ordering::Equal)
}),
ProcSort::Mem => rows.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
+ // Unknowns sink below every known figure, however small.
+ ProcSort::Power => rows.sort_by(|a, b| {
+ b.watts.unwrap_or(-1.0).partial_cmp(&a.watts.unwrap_or(-1.0)).unwrap_or(std::cmp::Ordering::Equal)
+ }),
+ ProcSort::Wakeups => rows.sort_by(|a, b| {
+ b.wakeups.unwrap_or(-1.0).partial_cmp(&a.wakeups.unwrap_or(-1.0)).unwrap_or(std::cmp::Ordering::Equal)
+ }),
+ }
+}
+
+/// Watts to one decimal; a dash below 0.05 W, so hundreds of idle rows do
+/// not read as fake precision, and for rows the meter has no figure for.
+pub fn format_watts(w: Option<f32>) -> String {
+ match w {
+ Some(w) if w >= 0.05 => format!("{:.1}", w),
+ _ => "\u{2014}".to_string(),
+ }
+}
+
+pub fn format_wakeups(w: Option<f32>) -> String {
+ match w {
+ Some(w) if w >= 0.5 => format!("{:.0}", w),
+ _ => "\u{2014}".to_string(),
}
}
+/// The line above the list: what was measured, what is baseline, what the
+/// column adds up to — and, when the column is dashes, why.
+pub fn summary_line(p: &PowerSummary) -> String {
+ let how = match p.mode {
+ Mode::Warming => return "Measuring power\u{2026}".to_string(),
+ Mode::Unavailable => {
+ return "Per-process power needs the battery discharging or readable RAPL counters \u{00b7} wakeups only"
+ .to_string()
+ }
+ Mode::Battery => "estimated from battery draw",
+ Mode::Rapl => "RAPL",
+ };
+ let mut parts = Vec::new();
+ if let Some(t) = p.total_w {
+ parts.push(format!("{:.1} W total", t));
+ }
+ if let Some(f) = p.floor_w {
+ parts.push(format!("{:.1} W baseline", f));
+ }
+ parts.push(format!("{:.1} W attributed to processes ({})", p.attributed_w, how));
+ parts.join(" \u{00b7} ")
+}
+
+/// nvidia-smi's draw figure, asked for only while the card is awake: the
+/// query itself would wake a suspended card, costing watts to report a zero.
+async fn dgpu_draw_w() -> Option<f64> {
+ if !power_meter::dgpu_awake() {
+ return None;
+ }
+ let out = tokio::process::Command::new("nvidia-smi")
+ .args(["--query-gpu=power.draw", "--format=csv,noheader,nounits"])
+ .output()
+ .await
+ .ok()?;
+ if !out.status.success() {
+ return None;
+ }
+ String::from_utf8_lossy(&out.stdout).lines().next()?.trim().parse().ok()
+}
+
/// Process name from a `ps … cmd` field: basename of argv[0], so cce binaries
/// longer than the kernel's 15-char `comm` cap display whole (`comm` showed
/// "cce-system-inte"). Kernel threads (`[kworker/0:1]`) keep their brackets.
@@ -98,6 +192,18 @@ pub fn format_rss(kb: u64) -> String {
}
pub async fn fetch_processes_state() -> ProcessesState {
+ let dgpu_w = dgpu_draw_w().await;
+ let attribution = tokio::task::spawn_blocking(move || {
+ let snap = power_meter::sample(dgpu_w);
+ METER.lock().unwrap().get_or_insert_with(Meter::new).tick(snap)
+ })
+ .await
+ .ok();
+ let power = attribution
+ .as_ref()
+ .map(|a| PowerSummary { mode: a.mode, total_w: a.total_w, floor_w: a.floor_w, attributed_w: a.attributed_w })
+ .unwrap_or_default();
+
let processes = {
let mut list = Vec::new();
if let Some(o) = tokio::process::Command::new("ps")
@@ -108,12 +214,18 @@ pub async fn fetch_processes_state() -> ProcessesState {
for line in text.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
+ let pp = parts[0]
+ .parse::<u32>()
+ .ok()
+ .and_then(|pid| attribution.as_ref().and_then(|a| a.per_pid.get(&pid)).copied());
list.push(ProcessRow {
pid: parts[0].to_string(),
cpu: parts[1].to_string(),
mem_pct: parts[2].to_string(),
rss_kb: parts[3].parse().unwrap_or(0),
command: command_display(&parts[4..].join(" ")),
+ watts: pp.and_then(|p| p.watts),
+ wakeups: pp.map(|p| p.wakeups_per_s),
});
}
}
@@ -127,6 +239,7 @@ pub async fn fetch_processes_state() -> ProcessesState {
cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
killing: std::collections::HashSet::new(),
sort: ProcSort::Cpu,
+ power,
}
}
@@ -148,8 +261,11 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
// between the list and the well's walls on all four sides.
let inset = 12.0;
let list_box_x = rx + inset;
- let list_box_y = sec.well_top() + inset;
let list_box_w = sec.cw - 2.0 * inset;
+ // Power summary line above the list; the list starts below it.
+ let summary_h = 18.0;
+ sec.pc.text(&summary_line(&state.power), list_box_x, sec.well_top() + inset + 2.0, 11.0, TEXT_DIM);
+ let list_box_y = sec.well_top() + inset + summary_h;
// Fill the page: the well's bottom wall lands at the page bottom,
// the list keeps its even inset inside the well.
let list_box_h = ((cy + ch) - inset - list_box_y).max(120.0);
@@ -161,11 +277,13 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
// subtracts scroll_x. CONTENT_W > box width = the h-bar appears.
const COL_PID: f32 = 12.0;
const COL_COMMAND: f32 = 80.0;
- const COL_RSS: f32 = 420.0;
- const COL_MEM: f32 = 510.0;
- const COL_CPU: f32 = 580.0;
- const COL_KILL: f32 = 624.0;
- const CONTENT_W: f32 = 650.0;
+ const COL_RSS: f32 = 400.0;
+ const COL_MEM: f32 = 480.0;
+ const COL_CPU: f32 = 545.0;
+ const COL_WATTS: f32 = 605.0;
+ const COL_WAKE: f32 = 655.0;
+ const COL_KILL: f32 = 720.0;
+ const CONTENT_W: f32 = 745.0;
let header_h = 22.0;
state.cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
@@ -197,6 +315,8 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
sec.pc.text(&mark("MEM", active(ProcSort::Mem)), list_box_x + COL_RSS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
sec.pc.text(&mark("CPU %", active(ProcSort::Cpu)), list_box_x + COL_CPU - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Cpu)));
+ sec.pc.text(&mark("W", active(ProcSort::Power)), list_box_x + COL_WATTS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Power)));
+ sec.pc.text(&mark("WAKE/s", active(ProcSort::Wakeups)), list_box_x + COL_WAKE - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Wakeups)));
// Invisible header hit targets (transparent, subtle hover), inside
// the header clip so they pan and cut with the labels. They share
@@ -207,6 +327,10 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
[0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
sec.pc.button("", list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
[0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
+ sec.pc.button("", list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
+ [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
+ sec.pc.button("", list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
+ [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
sec.pc.pop_clip_rect();
let row_h = 24.0;
@@ -236,12 +360,15 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
+ let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
+ sec.pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
+ sec.pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
// Kill button, in content space like the columns. Emitted
// AFTER the row button on purpose: overlapping page
@@ -283,6 +410,7 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
ProcessesMessage::Refreshed(new) => {
state.loaded = new.loaded;
state.processes = new.processes;
+ state.power = new.power;
// The fetch arrives cpu-ordered; a non-default sort re-applies so
// a refresh never silently flips the list back.
if state.sort != ProcSort::Cpu {
@@ -293,8 +421,8 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
state.killing.clear();
}
ProcessesMessage::SortBy(key) => {
- // Clicking the already-active Mem header toggles back to the Cpu
- // default; clicking CPU % is always a plain select.
+ // Clicking the already-active header (Mem, Power, Wakeups)
+ // toggles back to the Cpu default; CPU % is always a plain select.
state.sort = if state.sort == key { ProcSort::Cpu } else { key };
sort_rows(&mut state.processes, state.sort);
}
@@ -402,6 +530,8 @@ mod tests {
mem_pct: "2.0".to_string(),
rss_kb: 1024,
command: "proc".to_string(),
+ watts: None,
+ wakeups: None,
}
}
@@ -465,6 +595,8 @@ mod tests {
mem_pct: "0.0".to_string(),
rss_kb: rss,
command: "p".to_string(),
+ watts: None,
+ wakeups: None,
}
}
@@ -527,8 +659,8 @@ mod tests {
_ => None,
})
.collect();
- // MEM + MEM % both toggle Mem; CPU % selects Cpu.
- assert_eq!(sorts, [ProcSort::Mem, ProcSort::Mem, ProcSort::Cpu]);
+ // MEM + MEM % both toggle Mem; CPU % selects Cpu; then W and WAKE/s.
+ assert_eq!(sorts, [ProcSort::Mem, ProcSort::Mem, ProcSort::Cpu, ProcSort::Power, ProcSort::Wakeups]);
// Active-sort indicator rides the CPU % header by default.
assert!(pc.texts.iter().any(|t| t.0.starts_with("CPU %") && t.0.contains('\u{25bc}')));
}
@@ -555,4 +687,67 @@ mod tests {
assert_eq!(header_x(&pc1, "MEM %"), x0 - 40.0);
assert_eq!(header_x(&pc1, "CPU %"), header_x(&pc0, "CPU %") - 40.0);
}
+
+ #[test]
+ fn power_sort_puts_unknowns_last_and_toggles_back() {
+ let mut state = ProcessesState { loaded: true, ..Default::default() };
+ let mut a = sized_row("a", "9.0", 1);
+ let mut b = sized_row("b", "5.0", 1);
+ let c = sized_row("c", "1.0", 1);
+ a.watts = Some(0.2);
+ b.watts = Some(1.5);
+ state.processes = vec![a, b, c];
+ let order = |s: &ProcessesState| s.processes.iter().map(|p| p.pid.clone()).collect::<Vec<_>>();
+ update(&mut state, ProcessesMessage::SortBy(ProcSort::Power));
+ assert_eq!(order(&state), ["b", "a", "c"]);
+ update(&mut state, ProcessesMessage::SortBy(ProcSort::Power));
+ assert_eq!(state.sort, ProcSort::Cpu);
+ assert_eq!(order(&state), ["a", "b", "c"]);
+ }
+
+ #[test]
+ fn watts_and_wakeups_format_with_dashes_for_noise() {
+ assert_eq!(format_watts(None), "\u{2014}");
+ assert_eq!(format_watts(Some(0.04)), "\u{2014}");
+ assert_eq!(format_watts(Some(0.05)), "0.1");
+ assert_eq!(format_watts(Some(2.345)), "2.3");
+ assert_eq!(format_wakeups(None), "\u{2014}");
+ assert_eq!(format_wakeups(Some(0.2)), "\u{2014}");
+ assert_eq!(format_wakeups(Some(12.6)), "13");
+ }
+
+ #[test]
+ fn summary_line_states_mode_and_reason() {
+ let p = PowerSummary { mode: Mode::Battery, total_w: Some(16.0), floor_w: Some(10.0), attributed_w: 6.0 };
+ assert_eq!(
+ summary_line(&p),
+ "16.0 W total \u{00b7} 10.0 W baseline \u{00b7} 6.0 W attributed to processes (estimated from battery draw)"
+ );
+ assert!(summary_line(&PowerSummary::default()).starts_with("Measuring"));
+ let u = PowerSummary { mode: Mode::Unavailable, ..Default::default() };
+ assert!(summary_line(&u).contains("wakeups only"));
+ }
+
+ #[test]
+ fn refresh_carries_the_power_summary_and_rows_show_watts() {
+ let mut state = ProcessesState { loaded: true, ..Default::default() };
+ let mut r = sized_row("7", "1.0", 1);
+ r.watts = Some(1.26);
+ r.wakeups = Some(40.0);
+ let fresh = ProcessesState {
+ loaded: true,
+ processes: vec![r],
+ power: PowerSummary { mode: Mode::Battery, total_w: Some(20.0), floor_w: Some(15.0), attributed_w: 1.26 },
+ ..Default::default()
+ };
+ update(&mut state, ProcessesMessage::Refreshed(fresh));
+ assert_eq!(state.power.mode, Mode::Battery);
+ let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+ let mut ctx = cce_ui::context::UiContext::new();
+ let pc = view(&mut state, 10.0, 20.0, 900.0, 600.0, false, &[false], &mut layout, &mut ctx);
+ let all: Vec<&str> = pc.texts.iter().map(|t| t.0.as_str()).collect();
+ assert!(pc.texts.iter().any(|t| t.0 == "1.3"), "{all:?}");
+ assert!(pc.texts.iter().any(|t| t.0 == "40"));
+ assert!(pc.texts.iter().any(|t| t.0.starts_with("20.0 W total")));
+ }
}
diff --git a/src/power_meter.rs b/src/power_meter.rs
new file mode 100644
index 0000000..d05a456
--- /dev/null
+++ b/src/power_meter.rs
@@ -0,0 +1,642 @@
+//! Per-process power estimation for the Processes page.
+//!
+//! Nothing on a laptop reports watts per process, so this is an attribution
+//! model, not a measurement. What IS measured, per sample:
+//!
+//! - the whole-machine draw: `BAT0/power_now` while discharging, or the
+//! RAPL `psys` domain when its counter is readable (it is root-only by
+//! kernel default since the PLATYPUS fix, so on AC it usually is not);
+//! - per-domain CPU energy from RAPL `core` / `uncore` (uncore ≈ the iGPU on
+//! Intel client parts), again only when readable;
+//! - the discrete GPU's draw from nvidia-smi, only while the card is awake;
+//! - per process: CPU ticks from `/proc/<pid>/stat`, iGPU engine busy time
+//! from the i915 `fdinfo` of every `/dev/dri` fd it holds, context switches
+//! from `status` (wakeups/s, powertop's idle metric), and whether it holds a
+//! `/dev/nvidia*` fd.
+//!
+//! The split then works on *deltas* between two snapshots. Each measured
+//! source is reduced by its own rolling minimum over the last minute — the
+//! floor is the screen, radios and idle silicon, which no process owns — and
+//! only the part above the floor is handed out, in proportion to each pid's
+//! share of the activity that source responds to. Battery-only mode has one
+//! budget (total minus floor, minus whatever the dGPU took) split by CPU time
+//! with iGPU time folded in; RAPL mode has a budget per domain. Per-pid
+//! results are smoothed with a ~10s exponential average so the list does not
+//! reorder on every tick.
+//!
+//! Everything below [`Meter`] is pure and unit-tested on synthetic snapshots;
+//! [`sample`] is the only thing that touches the machine.
+
+use std::collections::{HashMap, VecDeque};
+use std::sync::OnceLock;
+use std::time::Instant;
+
+/// `/proc` CPU times are in USER_HZ ticks, which Linux fixes at 100 on every
+/// architecture whatever the kernel's own HZ is.
+pub const USER_HZ: f64 = 100.0;
+/// One nanosecond of iGPU engine time counted against one nanosecond of CPU
+/// core time in the battery-only split. A busy render engine and a busy core
+/// land in the same handful of watts on this class of part, so 1:1 is as
+/// honest as any other single figure; RAPL mode does not use it.
+pub const GPU_NS_WEIGHT: f64 = 1.0;
+/// How far back the rolling-minimum floor looks.
+pub const FLOOR_WINDOW_SECS: f64 = 60.0;
+/// Time constant of the per-pid smoothing.
+pub const SMOOTH_TAU_SECS: f64 = 10.0;
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ProcSample {
+ /// utime + stime, USER_HZ ticks.
+ pub cpu_ticks: u64,
+ /// Summed i915 engine busy time across the pid's DRM clients.
+ pub gpu_ns: u64,
+ /// voluntary + nonvoluntary context switches.
+ pub ctxt_switches: u64,
+ /// Holds an open `/dev/nvidia*` fd.
+ pub dgpu: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct RaplDomain {
+ /// sysfs `name`: `package-0`, `core`, `uncore`, `psys`, …
+ pub name: String,
+ pub energy_uj: u64,
+ /// `max_energy_range_uj` — the counter wraps at this.
+ pub range_uj: u64,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct Snapshot {
+ /// Seconds on an arbitrary monotonic clock.
+ pub t: f64,
+ /// Battery draw, only while discharging (the field is meaningless
+ /// otherwise, so the sampler leaves it None).
+ pub battery_w: Option<f64>,
+ pub rapl: Vec<RaplDomain>,
+ /// nvidia-smi power.draw, None while the card is runtime-suspended or
+ /// there is no driver.
+ pub dgpu_w: Option<f64>,
+ pub procs: HashMap<u32, ProcSample>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+ /// One snapshot so far, or a zero-length interval: nothing to attribute yet.
+ Warming,
+ /// RAPL core/uncore readable: CPU and iGPU budgets are measured per domain.
+ Rapl,
+ /// Only the battery's total is known: one budget, split by activity.
+ Battery,
+ /// On AC without RAPL access, or no battery: wakeups only.
+ Unavailable,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct PidPower {
+ /// Smoothed estimate; None when the mode cannot produce one.
+ pub watts: Option<f32>,
+ pub wakeups_per_s: f32,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Attribution {
+ pub mode: Mode,
+ /// Whole-machine draw this interval, when any source reports it.
+ pub total_w: Option<f32>,
+ /// Rolling-minimum of `total_w`: the unattributable baseline.
+ pub floor_w: Option<f32>,
+ /// Sum of every pid's smoothed estimate.
+ pub attributed_w: f32,
+ pub per_pid: HashMap<u32, PidPower>,
+}
+
+impl Attribution {
+ fn empty(mode: Mode) -> Self {
+ Self { mode, total_w: None, floor_w: None, attributed_w: 0.0, per_pid: HashMap::new() }
+ }
+}
+
+/// Rolling minimum over a time window.
+#[derive(Debug, Default)]
+struct Floor {
+ samples: VecDeque<(f64, f64)>,
+}
+
+impl Floor {
+ /// Record `w` at time `t`; return the minimum over the window ending now.
+ fn push(&mut self, t: f64, w: f64) -> f64 {
+ self.samples.push_back((t, w));
+ while let Some(&(t0, _)) = self.samples.front() {
+ if t - t0 > FLOOR_WINDOW_SECS {
+ self.samples.pop_front();
+ } else {
+ break;
+ }
+ }
+ self.samples.iter().map(|&(_, w)| w).fold(f64::INFINITY, f64::min)
+ }
+}
+
+/// Holds the previous snapshot, the floors and the smoothing state between
+/// ticks. One per page; the fetch keeps it in a static.
+#[derive(Debug, Default)]
+pub struct Meter {
+ prev: Option<Snapshot>,
+ floors: HashMap<&'static str, Floor>,
+ ema: HashMap<u32, f64>,
+}
+
+/// Counter delta with wrap-around at `range` (0 = no wrap known).
+fn wrapped_delta(prev: u64, next: u64, range: u64) -> u64 {
+ if next >= prev {
+ next - prev
+ } else if range > 0 {
+ range - prev + next
+ } else {
+ 0
+ }
+}
+
+impl Meter {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ fn floor(&mut self, key: &'static str, t: f64, w: f64) -> f64 {
+ self.floors.entry(key).or_default().push(t, w)
+ }
+
+ /// Budget a source hands out: its reading above its own rolling minimum.
+ fn budget(&mut self, key: &'static str, t: f64, w: Option<f64>) -> f64 {
+ match w {
+ Some(w) => (w - self.floor(key, t, w)).max(0.0),
+ None => 0.0,
+ }
+ }
+
+ pub fn tick(&mut self, snap: Snapshot) -> Attribution {
+ let Some(prev) = self.prev.replace(snap) else {
+ // Instantaneous sources seed their floors now, so the next
+ // tick's reading is measured against this one rather than
+ // against itself. RAPL is a counter and needs the interval.
+ let s = self.prev.as_ref().unwrap();
+ let (t, bat, dgpu) = (s.t, s.battery_w, s.dgpu_w);
+ if let Some(w) = bat {
+ self.floor("total", t, w);
+ }
+ if let Some(w) = dgpu {
+ self.floor("dgpu", t, w);
+ }
+ return Attribution::empty(Mode::Warming);
+ };
+ let snap = self.prev.as_ref().unwrap();
+ let dt = snap.t - prev.t;
+ if dt <= 0.0 {
+ return Attribution::empty(Mode::Warming);
+ }
+
+ // RAPL watts per domain over the interval.
+ let rapl_w = |name: &str| -> Option<f64> {
+ let a = prev.rapl.iter().find(|d| d.name == name)?;
+ let b = snap.rapl.iter().find(|d| d.name == name)?;
+ Some(wrapped_delta(a.energy_uj, b.energy_uj, b.range_uj) as f64 / 1e6 / dt)
+ };
+ let core = rapl_w("core");
+ let uncore = rapl_w("uncore");
+ let psys = rapl_w("psys");
+ let package = snap
+ .rapl
+ .iter()
+ .find(|d| d.name.starts_with("package"))
+ .and_then(|d| rapl_w(&d.name));
+
+ let total = snap.battery_w.or(psys).or(package);
+ let mode = if core.is_some() {
+ Mode::Rapl
+ } else if snap.battery_w.is_some() {
+ Mode::Battery
+ } else {
+ Mode::Unavailable
+ };
+
+ // Per-pid activity over the interval, pids present in both snapshots.
+ struct Delta {
+ cpu_ns: f64,
+ gpu_ns: f64,
+ wakeups: f64,
+ dgpu: bool,
+ }
+ let deltas: HashMap<u32, Delta> = snap
+ .procs
+ .iter()
+ .filter_map(|(pid, b)| {
+ let a = prev.procs.get(pid)?;
+ Some((
+ *pid,
+ Delta {
+ cpu_ns: b.cpu_ticks.saturating_sub(a.cpu_ticks) as f64 * 1e9 / USER_HZ,
+ gpu_ns: b.gpu_ns.saturating_sub(a.gpu_ns) as f64,
+ wakeups: b.ctxt_switches.saturating_sub(a.ctxt_switches) as f64 / dt,
+ dgpu: b.dgpu,
+ },
+ ))
+ })
+ .collect();
+
+ let t = snap.t;
+ let snap_dgpu = snap.dgpu_w;
+ let floor = total.map(|w| self.floor("total", t, w));
+ let dgpu_budget = self.budget("dgpu", t, snap_dgpu);
+ let dgpu_holders = deltas.values().filter(|d| d.dgpu).count() as f64;
+
+ // Raw per-pid watts for this interval.
+ let mut raw: HashMap<u32, f64> = HashMap::new();
+ let share = |get: &dyn Fn(&Delta) -> f64| -> HashMap<u32, f64> {
+ let sum: f64 = deltas.values().map(|d| get(d)).sum();
+ if sum <= 0.0 {
+ return HashMap::new();
+ }
+ deltas.iter().map(|(pid, d)| (*pid, get(d) / sum)).collect()
+ };
+ match mode {
+ Mode::Rapl => {
+ let cpu_budget = self.budget("core", t, core);
+ let gpu_budget = self.budget("uncore", t, uncore);
+ for (pid, s) in share(&|d| d.cpu_ns) {
+ *raw.entry(pid).or_default() += s * cpu_budget;
+ }
+ for (pid, s) in share(&|d| d.gpu_ns) {
+ *raw.entry(pid).or_default() += s * gpu_budget;
+ }
+ }
+ Mode::Battery => {
+ let dynamic = (total.unwrap() - floor.unwrap()).max(0.0);
+ let budget = (dynamic - dgpu_budget).max(0.0);
+ for (pid, s) in share(&|d| d.cpu_ns + GPU_NS_WEIGHT * d.gpu_ns) {
+ *raw.entry(pid).or_default() += s * budget;
+ }
+ }
+ Mode::Warming | Mode::Unavailable => {}
+ }
+ if mode != Mode::Unavailable && dgpu_holders > 0.0 {
+ for (pid, d) in &deltas {
+ if d.dgpu {
+ *raw.entry(*pid).or_default() += dgpu_budget / dgpu_holders;
+ }
+ }
+ }
+
+ // Smooth, and forget pids that are gone.
+ let alpha = 1.0 - (-dt / SMOOTH_TAU_SECS).exp();
+ self.ema.retain(|pid, _| deltas.contains_key(pid));
+ let mut per_pid = HashMap::with_capacity(deltas.len());
+ let mut attributed = 0.0;
+ for (pid, d) in &deltas {
+ let watts = if mode == Mode::Unavailable {
+ None
+ } else {
+ let r = raw.get(pid).copied().unwrap_or(0.0);
+ let e = match self.ema.get(pid) {
+ Some(&e) => e + alpha * (r - e),
+ None => r,
+ };
+ self.ema.insert(*pid, e);
+ attributed += e;
+ Some(e as f32)
+ };
+ per_pid.insert(*pid, PidPower { watts, wakeups_per_s: d.wakeups as f32 });
+ }
+
+ Attribution {
+ mode,
+ total_w: total.map(|w| w as f32),
+ floor_w: floor.map(|w| w as f32),
+ attributed_w: attributed as f32,
+ per_pid,
+ }
+ }
+}
+
+// ── The machine-facing half ──
+
+fn read_trim(path: &std::path::Path) -> Option<String> {
+ std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
+}
+
+fn clock_secs() -> f64 {
+ static START: OnceLock<Instant> = OnceLock::new();
+ START.get_or_init(Instant::now).elapsed().as_secs_f64()
+}
+
+/// utime + stime from a `/proc/<pid>/stat` line. The comm field can contain
+/// spaces and parens, so fields are counted from the LAST `)`.
+pub fn parse_stat_ticks(stat: &str) -> Option<u64> {
+ let rest = &stat[stat.rfind(')')? + 1..];
+ let f: Vec<&str> = rest.split_whitespace().collect();
+ // After ')': state ppid pgrp session tty tpgid flags minflt cminflt
+ // majflt cmajflt utime stime …
+ Some(f.get(11)?.parse::<u64>().ok()? + f.get(12)?.parse::<u64>().ok()?)
+}
+
+/// voluntary + nonvoluntary context switches from `/proc/<pid>/status`.
+pub fn parse_status_switches(status: &str) -> u64 {
+ status
+ .lines()
+ .filter_map(|l| {
+ let (k, v) = l.split_once(':')?;
+ if k.ends_with("ctxt_switches") { v.trim().parse::<u64>().ok() } else { None }
+ })
+ .sum()
+}
+
+/// (client id, summed engine busy ns) from one DRM fd's fdinfo. Capacity
+/// lines are counts, not times, and are skipped.
+pub fn parse_fdinfo_engines(fdinfo: &str) -> Option<(u64, u64)> {
+ let mut client = None;
+ let mut ns = 0u64;
+ for l in fdinfo.lines() {
+ let Some((k, v)) = l.split_once(':') else { continue };
+ let v = v.trim();
+ if k == "drm-client-id" {
+ client = v.parse().ok();
+ } else if k.starts_with("drm-engine-") && !k.starts_with("drm-engine-capacity") {
+ ns += v.split_whitespace().next().and_then(|n| n.parse::<u64>().ok()).unwrap_or(0);
+ }
+ }
+ client.map(|c| (c, ns))
+}
+
+fn sample_proc(dir: &std::path::Path) -> Option<ProcSample> {
+ let cpu_ticks = parse_stat_ticks(&std::fs::read_to_string(dir.join("stat")).ok()?)?;
+ let ctxt_switches =
+ std::fs::read_to_string(dir.join("status")).map(|s| parse_status_switches(&s)).unwrap_or(0);
+
+ // fd links are only readable for our own processes; others simply
+ // contribute no GPU time.
+ let mut clients: HashMap<u64, u64> = HashMap::new();
+ let mut dgpu = false;
+ if let Ok(fds) = std::fs::read_dir(dir.join("fd")) {
+ for fd in fds.flatten() {
+ let Ok(target) = std::fs::read_link(fd.path()) else { continue };
+ let Some(target) = target.to_str() else { continue };
+ if target.starts_with("/dev/nvidia") {
+ dgpu = true;
+ } else if target.starts_with("/dev/dri/") {
+ let info = dir.join("fdinfo").join(fd.file_name());
+ if let Some((client, ns)) =
+ std::fs::read_to_string(info).ok().and_then(|s| parse_fdinfo_engines(&s))
+ {
+ // Several fds can share one client; count it once.
+ clients.insert(client, ns);
+ }
+ }
+ }
+ }
+ Some(ProcSample { cpu_ticks, gpu_ns: clients.values().sum(), ctxt_switches, dgpu })
+}
+
+fn sample_rapl() -> Vec<RaplDomain> {
+ let mut out = Vec::new();
+ let Ok(entries) = std::fs::read_dir("/sys/class/powercap") else { return out };
+ for e in entries.flatten() {
+ let p = e.path();
+ if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("intel-rapl:")) {
+ continue;
+ }
+ let (Some(name), Some(energy)) = (read_trim(&p.join("name")), read_trim(&p.join("energy_uj")))
+ else {
+ continue;
+ };
+ let Ok(energy_uj) = energy.parse() else { continue };
+ let range_uj = read_trim(&p.join("max_energy_range_uj")).and_then(|s| s.parse().ok()).unwrap_or(0);
+ out.push(RaplDomain { name, energy_uj, range_uj });
+ }
+ out
+}
+
+fn sample_battery_w() -> Option<f64> {
+ let dir = crate::pages::power::battery_dir()?;
+ if read_trim(&dir.join("status"))? != "Discharging" {
+ return None;
+ }
+ read_trim(&dir.join("power_now"))?.parse::<f64>().ok().map(|uw| uw / 1e6)
+}
+
+/// Whether the NVIDIA card is awake. Polling nvidia-smi wakes a suspended
+/// card — a few watts, every tick, for a number that would be zero — so the
+/// caller only queries the draw when this says active.
+pub fn dgpu_awake() -> bool {
+ let Ok(devs) = std::fs::read_dir("/sys/bus/pci/drivers/nvidia") else { return false };
+ devs.flatten()
+ .any(|d| read_trim(&d.path().join("power/runtime_status")).as_deref() == Some("active"))
+}
+
+/// One pass over the machine. `dgpu_w` is passed in because it comes from an
+/// async nvidia-smi call the caller owns.
+pub fn sample(dgpu_w: Option<f64>) -> Snapshot {
+ let mut procs = HashMap::new();
+ if let Ok(entries) = std::fs::read_dir("/proc") {
+ for e in entries.flatten() {
+ let Some(pid) = e.file_name().to_str().and_then(|n| n.parse::<u32>().ok()) else { continue };
+ if let Some(s) = sample_proc(&e.path()) {
+ procs.insert(pid, s);
+ }
+ }
+ }
+ Snapshot { t: clock_secs(), battery_w: sample_battery_w(), rapl: sample_rapl(), dgpu_w, procs }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn proc(cpu_ticks: u64, gpu_ns: u64, ctxt: u64) -> ProcSample {
+ ProcSample { cpu_ticks, gpu_ns, ctxt_switches: ctxt, dgpu: false }
+ }
+
+ fn snap(t: f64, battery_w: Option<f64>, procs: &[(u32, ProcSample)]) -> Snapshot {
+ Snapshot { t, battery_w, rapl: Vec::new(), dgpu_w: None, procs: procs.iter().cloned().collect() }
+ }
+
+ fn w(a: &Attribution, pid: u32) -> f32 {
+ a.per_pid[&pid].watts.unwrap()
+ }
+
+ #[test]
+ fn first_tick_is_warming() {
+ let mut m = Meter::new();
+ let a = m.tick(snap(0.0, Some(20.0), &[(1, proc(0, 0, 0))]));
+ assert_eq!(a.mode, Mode::Warming);
+ assert!(a.per_pid.is_empty());
+ }
+
+ #[test]
+ fn battery_mode_splits_dynamic_by_cpu_share() {
+ let mut m = Meter::new();
+ m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
+ // 10 W floor (the minimum seen), 16 W now: 6 W to split 3:1.
+ let a = m.tick(snap(3.0, Some(16.0), &[(1, proc(300, 0, 0)), (2, proc(100, 0, 0))]));
+ assert_eq!(a.mode, Mode::Battery);
+ assert_eq!(a.total_w, Some(16.0));
+ assert_eq!(a.floor_w, Some(10.0));
+ assert!((w(&a, 1) - 4.5).abs() < 1e-4);
+ assert!((w(&a, 2) - 1.5).abs() < 1e-4);
+ assert!((a.attributed_w - 6.0).abs() < 1e-4);
+ }
+
+ #[test]
+ fn igpu_time_counts_in_battery_mode() {
+ let mut m = Meter::new();
+ m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
+ // pid 1: 1 s of CPU (100 ticks). pid 2: 1 s of render engine. Equal.
+ let a = m.tick(snap(1.0, Some(12.0), &[(1, proc(100, 0, 0)), (2, proc(0, 1_000_000_000, 0))]));
+ assert!((w(&a, 1) - w(&a, 2)).abs() < 1e-4);
+ }
+
+ #[test]
+ fn floor_is_the_rolling_minimum_and_never_goes_negative() {
+ let mut m = Meter::new();
+ m.tick(snap(0.0, Some(15.0), &[(1, proc(0, 0, 0))]));
+ let a = m.tick(snap(3.0, Some(12.0), &[(1, proc(50, 0, 0))]));
+ // The new low IS the floor: nothing above it to attribute.
+ assert_eq!(a.floor_w, Some(12.0));
+ assert_eq!(w(&a, 1), 0.0);
+ // 59 s after the 12 W low it is still in the window: 14 W now is
+ // 2 W above it, and that goes to the one busy pid.
+ let a = m.tick(snap(62.0, Some(14.0), &[(1, proc(100, 0, 0))]));
+ assert_eq!(a.floor_w, Some(12.0));
+ assert!(w(&a, 1) > 0.0);
+ // Once the low has aged out, the floor rises to what is left.
+ let a = m.tick(snap(70.0, Some(14.0), &[(1, proc(200, 0, 0))]));
+ assert_eq!(a.floor_w, Some(14.0));
+ }
+
+ #[test]
+ fn wakeups_are_switches_per_second_even_when_unavailable() {
+ let mut m = Meter::new();
+ m.tick(snap(0.0, None, &[(1, proc(0, 0, 100))]));
+ let a = m.tick(snap(2.0, None, &[(1, proc(0, 0, 160))]));
+ assert_eq!(a.mode, Mode::Unavailable);
+ assert_eq!(a.per_pid[&1].watts, None);
+ assert_eq!(a.per_pid[&1].wakeups_per_s, 30.0);
+ }
+
+ #[test]
+ fn exited_pids_drop_out_and_new_ones_wait_a_tick() {
+ let mut m = Meter::new();
+ m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
+ m.tick(snap(3.0, Some(12.0), &[(1, proc(100, 0, 0)), (2, proc(100, 0, 0))]));
+ let a = m.tick(snap(6.0, Some(12.0), &[(1, proc(200, 0, 0)), (3, proc(5, 0, 0))]));
+ assert!(!a.per_pid.contains_key(&2));
+ assert!(!a.per_pid.contains_key(&3), "no previous sample to diff against");
+ assert!(!m.ema.contains_key(&2));
+ }
+
+ #[test]
+ fn smoothing_moves_toward_the_new_value_not_onto_it() {
+ let mut m = Meter::new();
+ m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0))]));
+ let a = m.tick(snap(3.0, Some(16.0), &[(1, proc(300, 0, 0))]));
+ assert!((w(&a, 1) - 6.0).abs() < 1e-4, "first estimate is taken as-is");
+ // Activity stops: raw drops to 0, the average decays toward it.
+ let a = m.tick(snap(6.0, Some(10.0), &[(1, proc(300, 0, 0))]));
+ let v = w(&a, 1);
+ assert!(v > 0.0 && v < 6.0, "{v}");
+ }
+
+ fn rapl(name: &str, uj: u64, range: u64) -> RaplDomain {
+ RaplDomain { name: name.to_string(), energy_uj: uj, range_uj: range }
+ }
+
+ #[test]
+ fn rapl_mode_budgets_core_by_cpu_and_uncore_by_gpu() {
+ let mut m = Meter::new();
+ let mut s0 = snap(0.0, None, &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]);
+ s0.rapl = vec![rapl("package-0", 0, 0), rapl("core", 0, 0), rapl("uncore", 0, 0)];
+ m.tick(s0);
+ // Interval 1 establishes the floors (idle: 1 W core, 0.5 W uncore).
+ let mut s1 = snap(1.0, None, &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]);
+ s1.rapl = vec![rapl("package-0", 2_000_000, 0), rapl("core", 1_000_000, 0), rapl("uncore", 500_000, 0)];
+ m.tick(s1);
+ // Interval 2: core 5 W (4 above floor), uncore 2.5 W (2 above); pid 1
+ // did all the CPU work, pid 2 all the GPU work.
+ let mut s2 = snap(2.0, None, &[(1, proc(100, 0, 0)), (2, proc(0, 1_000_000_000, 0))]);
+ s2.rapl = vec![rapl("package-0", 10_000_000, 0), rapl("core", 6_000_000, 0), rapl("uncore", 3_000_000, 0)];
+ let a = m.tick(s2);
+ assert_eq!(a.mode, Mode::Rapl);
+ // Both pids already had a (zero) estimate from interval 1, so the
+ // 4 W and 2 W raw figures arrive through the smoothing step.
+ let alpha = 1.0 - (-1.0f64 / SMOOTH_TAU_SECS).exp();
+ assert!((w(&a, 1) as f64 - alpha * 4.0).abs() < 1e-4, "{}", w(&a, 1));
+ assert!((w(&a, 2) as f64 - alpha * 2.0).abs() < 1e-4, "{}", w(&a, 2));
+ // Total falls back to the package domain when neither battery nor psys report.
+ assert_eq!(a.total_w, Some(8.0));
+ }
+
+ #[test]
+ fn rapl_counter_wraps() {
+ assert_eq!(wrapped_delta(900, 100, 1000), 200);
+ assert_eq!(wrapped_delta(100, 900, 1000), 800);
+ assert_eq!(wrapped_delta(900, 100, 0), 0, "unknown range: no wrap guess");
+ }
+
+ #[test]
+ fn dgpu_budget_is_split_equally_among_fd_holders() {
+ let mut m = Meter::new();
+ let mut s0 = snap(0.0, Some(20.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0)), (3, proc(0, 0, 0))]);
+ s0.dgpu_w = Some(4.0);
+ m.tick(s0);
+ let hold = |ticks| ProcSample { cpu_ticks: ticks, gpu_ns: 0, ctxt_switches: 0, dgpu: true };
+ // Total +10 W, of which the dGPU rose 6 W: that 6 goes 3+3 to the two
+ // holders, and the remaining 4 follows CPU time (all pid 3's).
+ let mut s1 = snap(3.0, Some(30.0), &[(1, hold(0)), (2, hold(0)), (3, proc(100, 0, 0))]);
+ s1.dgpu_w = Some(10.0);
+ let a = m.tick(s1);
+ assert!((w(&a, 1) - 3.0).abs() < 1e-4);
+ assert!((w(&a, 2) - 3.0).abs() < 1e-4);
+ assert!((w(&a, 3) - 4.0).abs() < 1e-4);
+ }
+
+ /// Real-machine smoke: `cargo test -p cce-system-interface --lib
+ /// live_sample -- --ignored --nocapture`. Prints the sampler's cost, the
+ /// mode this host lands in, and the top estimates.
+ #[test]
+ #[ignore]
+ fn live_sample() {
+ let mut m = Meter::new();
+ let t0 = Instant::now();
+ let s0 = sample(None);
+ let cost = t0.elapsed();
+ eprintln!("sample: {:?} for {} pids, battery={:?}, rapl={:?}", cost, s0.procs.len(), s0.battery_w,
+ s0.rapl.iter().map(|d| d.name.as_str()).collect::<Vec<_>>());
+ m.tick(s0);
+ std::thread::sleep(std::time::Duration::from_secs(3));
+ let a = m.tick(sample(None));
+ eprintln!("mode={:?} total={:?} floor={:?} attributed={}", a.mode, a.total_w, a.floor_w, a.attributed_w);
+ let mut rows: Vec<_> = a.per_pid.iter().collect();
+ rows.sort_by(|x, y| y.1.watts.partial_cmp(&x.1.watts).unwrap());
+ for (pid, p) in rows.iter().take(8) {
+ eprintln!(" {pid}: {:?} W, {:.0} wake/s", p.watts, p.wakeups_per_s);
+ }
+ }
+
+ #[test]
+ fn stat_ticks_survive_spaces_and_parens_in_comm() {
+ 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";
+ assert_eq!(parse_stat_ticks(line), Some(325));
+ assert_eq!(parse_stat_ticks("garbage"), None);
+ }
+
+ #[test]
+ fn status_switches_sum_both_kinds() {
+ let s = "Name:\tx\nvoluntary_ctxt_switches:\t120\nnonvoluntary_ctxt_switches:\t5\n";
+ assert_eq!(parse_status_switches(s), 125);
+ }
+
+ #[test]
+ fn fdinfo_engines_sum_time_and_skip_capacity() {
+ 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";
+ assert_eq!(parse_fdinfo_engines(s), Some((7, 1015)));
+ assert_eq!(parse_fdinfo_engines("drm-driver:\ti915\n"), None);
+ }
+}