open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/rapl_hardware.rs
Transaction Science b9bf223411
All checks were successful
wai-quantum WebGPU member / does this change touch it (pull_request) Successful in 12s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 23s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 36s
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 39s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m34s
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Successful in 1m50s
energy meters on real hardware / sink decode table · per-task energy and the GPU rail on macOS (pull_request) Successful in 2m19s
energy meters on real hardware / sink decode table · RAPL on jetson-hub (NVML surveyed) (pull_request) Successful in 2m54s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m18s
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Successful in 1m30s
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Successful in 3m29s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 3m17s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 1m44s
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness (pull_request) Successful in 6m32s
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Successful in 8m42s
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Successful in 12m42s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 10m24s
wai-quantum WebGPU member / member · GPU, Vulkan (thor) (pull_request) Successful in 24m51s
wai-quantum WebGPU member / member · GPU, Vulkan (jetson-hub) (pull_request) Successful in 16m55s
jouleclaw-energy: RAPL MSR domains are per die on Intel, per package on AMD; die zones are packages
Linux scopes Intel's RAPL MSRs per die and AMD's and Hygon's per package
(arch/x86/events/rapl.c: "AMD/HYGON platforms have a per-PKG package energy
counter", "CLX-AP is multi-die and its RAPL MSRs are die-scope"), and the
powercap driver names a multi-die Intel package's zones package-<p>-die-<d>,
one top-level zone per die. Two consequences, both corrected here:

- The MSR fallback read each package on its first CPU, so on a multi-die Intel
  package it counted one die: an under-count, the unsafe direction at a
  ceiling. A domain is now (physical_package_id, die_id) on Intel (die 0
  where die_id is absent) and the package on AMD and Hygon, each read on its
  own lowest-numbered CPU and summed. Single-die parts are unchanged.
- The name rule accepted only package-<n>, so a multi-die host's die zones
  were not packages: no powercap package was listed. It now also accepts
  package-<n>-die-<m>, and nothing looser.

tests/rapl_hardware.rs read powercap only, so on a host with no powercap
package its oracle called the counters Unreadable while the meter, as root,
read them live through the MSRs, and the burn test had no zone to bracket.
The oracle now reads the MSRs itself where powercap lists no package, scoped
the same way, and brackets the meter's figure with outer and inner MSR reads.

On two bare-metal servers with no powercap zone (Xeon E-2278G; two Xeon
Silver 4215R sockets), the hardware tests now pass as root (oracle and meter
Live; 6.68 J and 22.07 J over a 300 ms burn, inside the oracle's MSR bracket)
and unprivileged (both Unavailable, the meter the estimator).

Tests: a two-die Intel package is two domains read on their own first CPUs
and summed; an AMD package whose CPUs list several die_ids is one domain; a
two-die powercap tree sums both die zones and not psys; the name rule's
accepts and near misses. Linux: unit and hardware tests pass (rapl,nvml);
macOS: unit tests pass, clippy clean in default, ioreport and rapl.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 21:20:28 -05:00

601 lines
26 KiB
Rust

//! Linux RAPL against the hardware (feature `rapl`): this host's powercap
//! counters, or its energy-status MSRs where powercap lists no package, read by
//! the meter and by an oracle the tests declare here.
//!
//! In a test binary of its own, so the RAPL probe runs in this process, and
//! the tests take one lock: each burns CPU and reads machine-wide counters.
//! The oracle reads `/sys/class/powercap` itself — every zone's `name`,
//! `energy_uj` and `max_energy_range_uj` — and applies the sustained-advance
//! rule to the top-level package zones (`package-<n>`, or `package-<n>-die-<m>`
//! on a multi-die package) on its own. Where powercap lists no package it reads
//! the RAPL MSRs itself (`oracle::msr`), each domain on its first CPU: per
//! package on AMD and Hygon, per die on Intel. None of these tests
//! passes without asserting: where the counters cannot be read (no powercap;
//! a CI container, whose `/sys/devices/virtual/powercap` Docker masks), the
//! liveness must say so and agree with the oracle; where they are live, both
//! must say live, the counter must be the meter's active one, and its
//! readings must be `HwShunt` over `CpuPkg` (the CPU package, not the GPU),
//! the figure over a CPU burn inside the oracle's package-only bracket and
//! clearly below package+psys and package+core.
#![cfg(all(target_os = "linux", feature = "rapl"))]
use std::path::Path;
use std::sync::{Mutex, MutexGuard};
use std::time::Duration;
use jouleclaw_energy::rapl::{rapl_liveness, RaplCounter};
use jouleclaw_energy::{meter, CounterLiveness, EnergyCounter, EnergyDomain, Provenance};
/// One test at a time: each burns CPU and reads machine-wide counters.
static SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// The tests' own reading of `/sys/class/powercap`: every zone listed, its
/// `name`, `energy_uj` and `max_energy_range_uj`, read here and nowhere else,
/// and the sustained-advance verdict over the package zones by the family's
/// rule. Nothing in it comes from the crate under test.
#[allow(dead_code)]
mod oracle {
use std::os::raw::{c_int, c_long};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// Where the kernel lists its powercap zones.
pub const POWERCAP: &str = "/sys/class/powercap";
#[repr(C)]
struct Timespec {
tv_sec: c_long,
tv_nsec: c_long,
}
unsafe extern "C" {
fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int;
}
/// `CLOCK_THREAD_CPUTIME_ID` in Linux `<linux/time.h>`.
const CLOCK_THREAD_CPUTIME_ID: c_int = 3;
/// This thread's CPU time.
pub fn thread_cpu() -> Duration {
let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 };
assert_eq!(unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }, 0, "clock_gettime");
Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)
}
/// Spin this thread until it has used `d` of its own CPU time, or `wall_cap`
/// of wall time has passed. Returns the CPU it got.
pub fn burn(d: Duration, wall_cap: Duration) -> Duration {
let (started, wall) = (thread_cpu(), Instant::now());
let mut acc: u64 = 0;
while thread_cpu() - started < d && wall.elapsed() < wall_cap {
for _ in 0..1_000 {
acc = std::hint::black_box(acc.wrapping_add(1));
}
}
thread_cpu() - started
}
/// One entry of the powercap directory.
#[derive(Debug, Clone)]
pub struct Zone {
/// The directory entry: `intel-rapl:0`, `intel-rapl:0:0`, `intel-rapl-mmio:0`, ...
pub dir: String,
pub name: Option<String>,
pub energy_uj: Option<u64>,
pub max_uj: Option<u64>,
}
fn text(p: PathBuf) -> Option<String> {
std::fs::read_to_string(p).ok().map(|s| s.trim().to_string())
}
fn number(p: PathBuf) -> Option<u64> {
text(p)?.parse().ok()
}
/// Every entry under `root`, in name order.
pub fn zones(root: &Path) -> Vec<Zone> {
let Ok(entries) = std::fs::read_dir(root) else { return Vec::new() };
let mut zones: Vec<Zone> = entries
.flatten()
.map(|e| {
let dir = e.file_name().to_string_lossy().into_owned();
let p = e.path();
Zone { name: text(p.join("name")), energy_uj: number(p.join("energy_uj")), max_uj: number(p.join("max_energy_range_uj")), dir }
})
.collect();
zones.sort_by(|a, b| a.dir.cmp(&b.dir));
zones
}
fn digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
/// A top-level RAPL zone: `intel-rapl:<n>`. Not a sub-zone, not MMIO.
pub fn top_level(z: &Zone) -> bool {
z.dir.strip_prefix("intel-rapl:").is_some_and(digits)
}
/// A package zone: top-level, named `package-<n>`, or `package-<n>-die-<m>`
/// (one zone per die of a multi-die Intel package).
pub fn is_package(z: &Zone) -> bool {
top_level(z)
&& z.name.as_deref().and_then(|n| n.strip_prefix("package-")).is_some_and(|n| match n.split_once("-die-") {
Some((package, die)) => digits(package) && digits(die),
None => digits(n),
})
}
/// The platform zone.
pub fn is_psys(z: &Zone) -> bool {
top_level(z) && z.name.as_deref() == Some("psys")
}
/// A package's `core` sub-zone (`intel-rapl:<n>:<m>` named `core`).
pub fn is_core(z: &Zone) -> bool {
z.dir.strip_prefix("intel-rapl:").is_some_and(|r| r.contains(':')) && z.name.as_deref() == Some("core")
}
/// One zone's energy between two reads, modulo its own range.
pub fn delta(e0: u64, e1: u64, max: Option<u64>) -> Option<u64> {
if e1 >= e0 {
Some(e1 - e0)
} else {
max.filter(|&m| m > 0)?.checked_sub(e0)?.checked_add(e1)
}
}
/// `energy_uj` of each of `dirs` under `root`, in order; `None` if any fails.
pub fn read(root: &Path, dirs: &[&Zone]) -> Option<Vec<u64>> {
dirs.iter().map(|z| number(root.join(&z.dir).join("energy_uj"))).collect()
}
/// The summed energy of `which` of `set` between two reads of `set`.
pub fn energy(set: &[&Zone], before: &[u64], after: &[u64], which: impl Fn(&Zone) -> bool) -> u64 {
set.iter()
.zip(before.iter().zip(after))
.filter(|(z, _)| which(z))
.map(|(z, (&b, &a))| delta(b, a, z.max_uj).expect("a zone fell with no readable range"))
.sum()
}
/// What the oracle concluded about the package counters under a root.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
/// No top-level zone's `energy_uj` reads (no powercap, a masked or
/// root-only tree).
Unreadable,
/// Top-level zones read, but none is a package.
NoPackage,
/// Every package read, but not every one kept advancing.
Frozen,
/// Every package advanced in at least five intervals, at least 80% of
/// them, over at least 300 ms.
Live,
}
/// The oracle's sustained-advance run over the package zones under `root`.
#[derive(Debug)]
pub struct Watch {
pub verdict: Verdict,
pub packages: Vec<String>,
pub intervals: u32,
pub advances: Vec<u32>,
pub window: Duration,
}
impl Watch {
/// The verdict and each package's advances, for the test's log line.
pub fn summary(&self) -> String {
let per: Vec<String> =
self.packages.iter().zip(&self.advances).map(|(p, a)| format!("{p} advanced {a}/{}", self.intervals)).collect();
format!("{:?} ({} over {} ms)", self.verdict, per.join(", "), self.window.as_millis())
}
}
/// Sample every package's `energy_uj` under `root`, burning 25 ms of this
/// thread's CPU between samples (capped at 500 ms of wall time), until every
/// package has advanced in at least five intervals, at least 80% of those
/// observed, over at least 300 ms; or one second has passed with at least
/// five intervals; or five seconds have.
pub fn watch(root: &Path) -> Watch {
let all = zones(root);
let packages: Vec<&Zone> = all.iter().filter(|z| is_package(z)).collect();
let names: Vec<String> = packages.iter().map(|z| z.dir.clone()).collect();
if packages.is_empty() {
let readable = all.iter().any(|z| top_level(z) && z.energy_uj.is_some());
return Watch::none(if readable { Verdict::NoPackage } else { Verdict::Unreadable }, names);
}
watch_with(names, || read(root, &packages))
}
/// [`watch`] over this host: its powercap package zones, or, where powercap
/// lists none, its RAPL MSRs ([`msr::domains`]), sampled the same way.
pub fn watch_host() -> Watch {
let root = Path::new(POWERCAP);
if zones(root).iter().any(is_package) {
return watch(root);
}
match msr::domains() {
Some(domains) => {
let names = domains.iter().map(|d| d.name.clone()).collect();
watch_with(names, || msr::read_all(&domains))
}
None => {
let w = watch(root);
Watch::none(w.verdict, w.packages)
}
}
}
impl Watch {
fn none(verdict: Verdict, packages: Vec<String>) -> Watch {
Watch { verdict, packages, intervals: 0, advances: Vec::new(), window: Duration::ZERO }
}
}
/// The sustained-advance run over the counters `read` returns, one per name.
fn watch_with(names: Vec<String>, mut read: impl FnMut() -> Option<Vec<u64>>) -> Watch {
let started = Instant::now();
let Some(mut prev) = read() else { return Watch::none(Verdict::Unreadable, names) };
let mut advances = vec![0u32; names.len()];
let mut intervals = 0u32;
let live = |adv: &[u32], n: u32, w: Duration| {
w >= Duration::from_millis(300) && adv.iter().all(|&a| a >= 5 && u64::from(a) * 5 >= u64::from(n) * 4)
};
loop {
let el = started.elapsed();
if live(&advances, intervals, el) || el >= Duration::from_secs(5) || (el >= Duration::from_secs(1) && intervals >= 5) {
break;
}
burn(Duration::from_millis(25), Duration::from_millis(500));
if let Some(now) = read() {
intervals += 1;
for ((a, n), p) in advances.iter_mut().zip(&now).zip(&prev) {
*a += u32::from(n > p);
}
prev = now;
}
}
let window = started.elapsed();
let verdict = if live(&advances, intervals, window) { Verdict::Live } else { Verdict::Frozen };
Watch { verdict, packages: names, intervals, advances, window }
}
/// The oracle's own reading of the RAPL energy-status MSRs, for a host
/// whose powercap lists no package: each domain's count through the `msr`
/// driver's device file of its first CPU. A domain is a package on AMD and
/// Hygon (a per-package counter) and a die of a package on Intel (the
/// kernel's die-scope RAPL MSRs).
pub mod msr {
use super::number;
use std::os::unix::fs::FileExt;
use std::path::{Path, PathBuf};
/// One RAPL domain's energy-status MSR.
pub struct Domain {
pub name: String,
device: PathBuf,
register: u64,
shift: u32,
}
fn read(device: &Path, register: u64) -> Option<u64> {
let file = std::fs::File::open(device).ok()?;
let mut raw = [0u8; 8];
file.read_exact_at(&mut raw, register).ok()?;
Some(u64::from_le_bytes(raw))
}
/// Every domain whose power-unit and energy-status MSRs read with a
/// non-zero unit; `None` when the CPU's MSRs are not known here, no
/// topology is listed, or any domain cannot be read.
pub fn domains() -> Option<Vec<Domain>> {
let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").ok()?;
let field = |key: &str| {
cpuinfo.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim() == key).map(|(_, v)| v.trim()))
};
let family: u32 = field("cpu family")?.parse().ok()?;
let (unit, energy, per_die) = match field("vendor_id")? {
"GenuineIntel" if family == 6 => (0x606, 0x611, true),
"AuthenticAMD" | "HygonGenuine" if family >= 0x17 => (0xC001_0299, 0xC001_029B, false),
_ => return None,
};
let mut first = std::collections::BTreeMap::<(u64, u64), u32>::new();
for entry in std::fs::read_dir("/sys/devices/system/cpu").ok()?.flatten() {
let Some(cpu) = entry.file_name().to_str().and_then(|n| n.strip_prefix("cpu")?.parse::<u32>().ok()) else {
continue;
};
let topology = entry.path().join("topology");
let Some(package) = number(topology.join("physical_package_id")) else { continue };
let die = if per_die { number(topology.join("die_id")).unwrap_or(0) } else { 0 };
let lowest = first.entry((package, die)).or_insert(cpu);
*lowest = (*lowest).min(cpu);
}
if first.is_empty() {
return None;
}
first
.into_iter()
.map(|((package, die), cpu)| {
let device = PathBuf::from(format!("/dev/cpu/{cpu}/msr"));
let shift = ((read(&device, unit)? >> 8) & 0x1F) as u32;
if shift == 0 {
return None;
}
read(&device, energy)?;
Some(Domain { name: format!("msr package-{package}-die-{die} (cpu {cpu})"), device, register: energy, shift })
})
.collect()
}
/// One domain's count, in µJ.
fn uj(d: &Domain) -> Option<u64> {
read(&d.device, d.register).map(|raw| ((u128::from(raw & 0xFFFF_FFFF) * 1_000_000) >> d.shift) as u64)
}
/// Every domain's count, in µJ; `None` if one fails.
pub fn read_all(domains: &[Domain]) -> Option<Vec<u64>> {
domains.iter().map(uj).collect()
}
/// The domains' summed energy between two reads, each modulo its 2^32 counts.
pub fn energy(domains: &[Domain], before: &[u64], after: &[u64]) -> u64 {
domains
.iter()
.zip(before.iter().zip(after))
.map(|(d, (&b, &a))| {
let range = (((1u128 << 32) * 1_000_000) >> d.shift) as u64;
super::delta(b, a, Some(range)).expect("an MSR count above its range")
})
.sum()
}
}
/// The zones under `root`, as one line for the log.
pub fn listing(root: &Path) -> String {
zones(root)
.iter()
.map(|z| {
let kind = if is_package(z) {
"package"
} else if is_psys(z) {
"psys"
} else if is_core(z) {
"core sub-zone"
} else if top_level(z) {
"top-level"
} else {
"not summed"
};
let e = z.energy_uj.map_or("unreadable".to_string(), |e| e.to_string());
let m = z.max_uj.map_or("-".to_string(), |m| m.to_string());
format!("{}={} [{kind}] energy_uj={e} max={m}", z.dir, z.name.as_deref().unwrap_or("?"))
})
.collect::<Vec<_>>()
.join("; ")
}
/// The CI container's view of the host's powercap directory: the same
/// entries, each a symlink into `/sys/devices/virtual/powercap`, which
/// Docker masks with an empty tmpfs, so every target is missing. Built
/// from this host's own listing; removed when dropped.
pub struct MaskedView(pub PathBuf);
impl MaskedView {
/// Build the masked view of this host's powercap entries in a
/// temporary directory named by `tag` and this process.
pub fn of_host(tag: &str) -> MaskedView {
let root = std::env::temp_dir().join(format!("{tag}-masked-powercap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create the masked view");
let masked = root.join("masked-devices-virtual-powercap");
for z in zones(Path::new(POWERCAP)) {
std::os::unix::fs::symlink(masked.join(&z.dir), root.join(&z.dir)).expect("symlink");
}
MaskedView(root)
}
}
impl Drop for MaskedView {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A bracket of reads around the meter's two reads: every package, psys
/// and core zone read at four points, outer and inner on each side.
pub struct Bracket<'a> {
pub set: Vec<&'a Zone>,
pub reads: [Vec<u64>; 4],
}
impl Bracket<'_> {
/// The packages' energy over the outer bracket, in µJ: the most a
/// package-only figure read inside it can be.
pub fn package_outer(&self) -> u64 {
energy(&self.set, &self.reads[0], &self.reads[3], is_package)
}
/// The packages' energy over the inner bracket, in µJ: the least a
/// package-only figure read around it can be.
pub fn package_inner(&self) -> u64 {
energy(&self.set, &self.reads[1], &self.reads[2], is_package)
}
/// The psys zone's energy over the inner bracket, in µJ.
pub fn psys_inner(&self) -> u64 {
energy(&self.set, &self.reads[1], &self.reads[2], is_psys)
}
/// The core sub-zones' energy over the inner bracket, in µJ.
pub fn core_inner(&self) -> u64 {
energy(&self.set, &self.reads[1], &self.reads[2], is_core)
}
/// Whether the bracket read a zone of the kind `which` picks.
pub fn has(&self, which: impl Fn(&Zone) -> bool) -> bool {
self.set.iter().any(|z| which(z))
}
}
/// The family's rule on a figure `uj` the meter read between the
/// bracket's inner and outer reads: no less than the packages' energy
/// over the inner bracket, no more than over the outer one, and below
/// package+psys and package+core where those zones exist. A figure that
/// added a zone reads at least the packages' and that zone's energy over
/// the inner bracket, so `uj < inner + zone` is the double-count test; it
/// can only be put where the zone moved more over the burn than the
/// packages did in the gap between the outer and inner reads (else a
/// correct figure could reach it too), and the line says when it could
/// not. Returns the figures, for the log.
pub fn assert_package_only(b: &Bracket, uj: u64) -> String {
let (inner, outer, psys, core) = (b.package_inner(), b.package_outer(), b.psys_inner(), b.core_inner());
let gap = outer - inner;
let mut line = format!(
"meter {uj} µJ; oracle package-only inner {inner} outer {outer} µJ (gap {gap}); psys inner {psys} µJ; \
core inner {core} µJ"
);
assert!(inner > 0, "the packages did not advance over the burn: {line}");
assert!(inner <= uj && uj <= outer, "the figure is outside the package-only bracket: {line}");
for (zone, present, moved) in [("psys", b.has(is_psys), psys), ("core", b.has(is_core), core)] {
if !present {
continue;
}
assert!(moved > 0, "{zone} did not advance: {line}");
if moved > gap {
assert!(uj < inner + moved, "the figure reaches package+{zone}: {zone} was added: {line}");
line.push_str(&format!("; below package+{zone}"));
} else {
line.push_str(&format!("; {zone} moved less than the gap, so only the outer bound tests it"));
}
}
line
}
/// Every package, psys and core zone of `all` whose counter reads.
pub fn bracket_set(all: &[Zone]) -> Vec<&Zone> {
all.iter().filter(|z| (is_package(z) || is_psys(z) || is_core(z)) && z.energy_uj.is_some()).collect()
}
/// One oracle read of every zone of `set`; panics if one fails.
pub fn read_all(set: &[&Zone]) -> Vec<u64> {
read(Path::new(POWERCAP), set).expect("an oracle read of the bracket zones")
}
/// Read the bracket around `meter_read` (called twice) and `work`:
/// outer, meter, inner, work, inner, meter, outer.
pub fn around<'a, M>(set: Vec<&'a Zone>, mut meter_read: impl FnMut() -> M, work: impl FnOnce()) -> (Bracket<'a>, M, M) {
let o0 = read_all(&set);
let m0 = meter_read();
let o1 = read_all(&set);
work();
let o2 = read_all(&set);
let m1 = meter_read();
let o3 = read_all(&set);
(Bracket { set, reads: [o0, o1, o2, o3] }, m0, m1)
}
}
use oracle::Verdict;
/// The liveness agrees with the oracle's own verdict, and only a live
/// counter is available and is the meter's active one.
#[test]
fn the_rapl_liveness_agrees_with_the_oracle_and_gates_the_counter() {
let _one = serial();
let liveness = rapl_liveness();
let watch = oracle::watch_host();
let counter = RaplCounter::new();
let composite = meter::detect_meter();
eprintln!(
"rapl hardware: {}; oracle {}; liveness={liveness:?} available={} active={} meters={:?}",
oracle::listing(Path::new(oracle::POWERCAP)),
watch.summary(),
counter.is_available(),
composite.active_name(),
composite.names()
);
assert_eq!(liveness == CounterLiveness::Live, watch.verdict == Verdict::Live, "the liveness disagrees with the oracle");
match watch.verdict {
Verdict::Unreadable | Verdict::NoPackage => assert_eq!(liveness, CounterLiveness::Unavailable),
Verdict::Frozen => assert_eq!(liveness, CounterLiveness::Frozen),
Verdict::Live => {}
}
assert_eq!(counter.liveness(), liveness, "the counter was built on another verdict");
assert_eq!(counter.is_available(), liveness == CounterLiveness::Live, "availability disagrees with the liveness");
assert_eq!(composite.active_name() == "rapl", liveness == CounterLiveness::Live, "the meter's choice disagrees");
if !counter.is_available() {
assert!(counter.read().is_err(), "a counter that is not live read");
}
}
/// Over a 300 ms one-thread CPU burn, the counter's readings are `HwShunt`
/// over `CpuPkg`, and its figure sits inside the oracle's package-only
/// bracket and clearly below package+psys and package+core.
#[test]
fn a_cpu_burn_reads_inside_the_package_only_bracket() {
let _one = serial();
let counter = RaplCounter::new();
if !counter.is_available() {
let watch = oracle::watch_host();
eprintln!("rapl burn: counter {:?}; oracle {}", counter.liveness(), watch.summary());
assert_ne!(watch.verdict, Verdict::Live, "the oracle saw live packages, yet the counter is not available");
assert!(counter.read().is_err());
return;
}
let all = oracle::zones(Path::new(oracle::POWERCAP));
if !all.iter().any(oracle::is_package) {
// No powercap package: the counter reads the MSRs, and so does the oracle,
// outer and inner around the meter's reads.
let domains = oracle::msr::domains().expect("the counter reads the MSRs, so the oracle can");
let read = || oracle::msr::read_all(&domains).expect("an oracle MSR read");
let o0 = read();
let r0 = counter.read().expect("a live counter reads");
let o1 = read();
let cpu = oracle::burn(Duration::from_millis(300), Duration::from_secs(10));
let o2 = read();
let r1 = counter.read().expect("a live counter reads");
let o3 = read();
for r in [&r0, &r1] {
assert_eq!((r.domain, r.provenance), (EnergyDomain::CpuPkg, Provenance::HwShunt), "{r:?}");
}
let (uj, inner, outer) =
(r1.uj - r0.uj, oracle::msr::energy(&domains, &o1, &o2), oracle::msr::energy(&domains, &o0, &o3));
let names: Vec<&str> = domains.iter().map(|d| d.name.as_str()).collect();
eprintln!("rapl burn (MSR {names:?}): {cpu:?} of thread CPU; meter {uj} µJ; oracle inner {inner} outer {outer} µJ");
assert!(inner > 0, "the domains did not advance over the burn");
assert!(inner <= uj && uj <= outer, "the figure {uj} µJ is outside the oracle's MSR bracket [{inner}, {outer}]");
assert!(cpu >= Duration::from_millis(300), "the burn got only {cpu:?} of CPU");
return;
}
let mut cpu = Duration::ZERO;
let (b, r0, r1) = oracle::around(
oracle::bracket_set(&all),
|| counter.read().expect("a live counter reads"),
|| cpu = oracle::burn(Duration::from_millis(300), Duration::from_secs(10)),
);
for r in [&r0, &r1] {
assert_eq!((r.domain, r.provenance), (EnergyDomain::CpuPkg, Provenance::HwShunt), "{r:?}");
}
eprintln!("rapl burn: {cpu:?} of thread CPU; {}", oracle::assert_package_only(&b, r1.uj - r0.uj));
assert!(cpu >= Duration::from_millis(300), "the burn got only {cpu:?} of CPU");
}
/// What a CI container sees: Docker masks `/sys/devices/virtual/powercap`
/// with an empty tmpfs, so every entry of `/sys/class/powercap` is a symlink
/// to nothing. On that view of this host's own listing the counter is
/// unavailable and reads nothing, and the oracle agrees.
#[test]
fn the_ci_containers_masked_view_is_unavailable() {
let _one = serial();
let view = oracle::MaskedView::of_host("jouleclaw-energy");
let watch = oracle::watch(&view.0);
let counter = RaplCounter::with_root(&view.0);
eprintln!("rapl masked view: oracle {}; counter {:?} available={}", watch.summary(), counter.liveness(), counter.is_available());
assert!(matches!(watch.verdict, Verdict::Unreadable | Verdict::NoPackage), "{watch:?}");
assert_eq!((counter.liveness(), counter.is_available()), (CounterLiveness::Unavailable, false));
assert!(counter.read().is_err());
}