All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 10s
energy meters on real hardware / sink decode table · per-task energy and the GPU rail on macOS (pull_request) Successful in 2m16s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 2m26s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 1m0s
standards workspaces build + test / proof-rs (proof/conformance/v1/joulespec.toml, proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 2m24s
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 36s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 6m4s
energy meters on real hardware / sink decode table · RAPL on jetson-hub (NVML surveyed) (pull_request) Successful in 2m25s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 10m58s
Linux scopes Intel's RAPL energy-status MSRs per die: its RAPL PMU treats the package counter as package-scope only on AMD and Hygon (rapl_pkg_pmu_is_pkg_scope(), 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 its powercap driver names a zone package-<p>-die-<d> where the MSRs are not package-scope and a package holds more than one die (intel_rapl_common.c). The MSR fallback grouped CPUs by physical_package_id alone, so on a multi-die Intel package it read one die and missed the rest: an under-count, the unsafe direction at a ceiling. MSR fallback: on Intel family 6 a domain is (physical_package_id, die_id), die 0 where the kernel lists no die_id; on AMD Zen and Hygon it stays the package, whatever die_id says. Each domain is read on its lowest-numbered CPU and is a package zone of its own, indexed in (package, die) order, so each die is a rail of its own in the liveness rule and every die is summed. All-or-nothing, the zero unit, the wrap and powercap preferred are as before. (MsrPackage is now MsrDomain.) Powercap: is_package_name also accepts package-<digits>-die-<digits>, and nothing looser, so a multi-die host's per-die zones are its packages: powercap lists a package (the MSRs are not read), and both die zones are summed. Before, such a host listed no package and fell to the MSRs, as root only. The RAPL hardware test's oracle follows the same rules with its own code. Tests (fake files): two Intel packages of two dies each, four domains each read on its own die's first CPU and summed; an AMD package whose CPUs carry two die_ids, one domain; an Intel host with no die_id files, per package; a two-die powercap tree summing both die zones and not psys; and the name rule (package-0-die-0 yes; package-0-die-, package-0-die, package--die-0, package-0-die-x, package-0-core-0 no). proof-cascade and proof-cli tests pass on Linux, proof-cascade on macOS (with its ioreport liveness grep); clippy is clean with -D warnings on Linux (proof-cascade, proof-cli) and macOS (proof-cascade, proof-solvers). Co-Authored-By: Claude Opus 5.5 <[email protected]>
598 lines
26 KiB
Rust
598 lines
26 KiB
Rust
//! Linux RAPL against the hardware: this host's powercap counters, read by
|
|
//! the meter and by an oracle the tests declare here.
|
|
//!
|
|
//! In a test binary of its own, so the RAPL probe behind `rapl_status()`
|
|
//! 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-<n>` zones
|
|
//! on its own. 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 status must say so and
|
|
//! agree with the oracle; where they are live, both must say live, RAPL must
|
|
//! be the meter's source, and the marginal figure of a burn on every spare
|
|
//! core must be `HwShunt`, claim the CPU package and not the GPU, and, summed
|
|
//! over the calls, fit inside the oracle's package-only bracket around the
|
|
//! whole measurement (idle and busy windows), which a figure that added
|
|
//! psys or a package's core sub-zone overruns.
|
|
//!
|
|
//! A package zone is one named `package-<n>`, or, on a multi-die Intel
|
|
//! package, each die's `package-<n>-die-<m>`. Where powercap lists no package
|
|
//! (a kernel without the powercap driver), the meter reads each RAPL domain's
|
|
//! energy-status MSR instead, and so does the oracle, with its own reader:
|
|
//! each die on Intel and each package on AMD and Hygon, as Linux scopes them,
|
|
//! read on its lowest-numbered CPU's `/dev/cpu/<n>/msr` at the vendor's
|
|
//! registers, the 32-bit count at the domain's unit. Those domains join the
|
|
//! oracle's zones as `msr-package:<p>` or `msr-package:<p>-die-<d>`, named
|
|
//! `package-<p>` or `package-<p>-die-<d>`, and every test above runs over
|
|
//! them unchanged; without `CAP_SYS_RAWIO` they do not read, and the status
|
|
//! must say so.
|
|
|
|
#![cfg(target_os = "linux")]
|
|
|
|
use std::path::Path;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Mutex, MutexGuard};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use proof_cascade::meter::{self, rapl_status, CounterStatus, MeterSource};
|
|
use proof_schema::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, or one package's energy-status
|
|
/// MSR where powercap lists no package.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Zone {
|
|
/// The directory entry: `intel-rapl:0`, `intel-rapl:0:0`, `intel-rapl-mmio:0`, ...;
|
|
/// `msr-package:<id>` for an MSR package.
|
|
pub dir: String,
|
|
pub name: Option<String>,
|
|
pub energy_uj: Option<u64>,
|
|
pub max_uj: Option<u64>,
|
|
/// How to read an MSR package; `None` for a powercap entry.
|
|
pub msr: Option<Msr>,
|
|
}
|
|
|
|
/// One package's energy-status MSR, as the oracle reads it.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Msr {
|
|
/// `/dev/cpu/<the package's lowest-numbered CPU>/msr`.
|
|
pub device: PathBuf,
|
|
/// The energy-status register: 0x611 (Intel), 0xC001029B (AMD, Hygon).
|
|
pub register: u64,
|
|
/// The unit: 2^-shift J per count, bits 12:8 of the power-unit MSR.
|
|
pub shift: u32,
|
|
}
|
|
|
|
/// The 8 bytes at offset `register` of the `msr` device file `device`.
|
|
fn msr_read(device: &Path, register: u64) -> Option<u64> {
|
|
use std::os::unix::fs::FileExt;
|
|
let mut b = [0u8; 8];
|
|
std::fs::File::open(device).ok()?.read_exact_at(&mut b, register).ok()?;
|
|
Some(u64::from_le_bytes(b))
|
|
}
|
|
|
|
/// The microjoules in the 32-bit count of `raw`, at 2^-shift J a count.
|
|
fn msr_uj(raw: u64, shift: u32) -> u64 {
|
|
(u128::from(raw & 0xFFFF_FFFF) * 1_000_000 / (1u128 << shift)) as u64
|
|
}
|
|
|
|
/// This host's RAPL domains' energy-status MSRs, as zones: each die on
|
|
/// Intel family 6 (0x606 / 0x611; `die_id` within `physical_package_id`,
|
|
/// 0 where the kernel lists none), `msr-package:<p>-die-<d>` named
|
|
/// `package-<p>-die-<d>`; each package on AMD family 0x17 and later and
|
|
/// Hygon (0xC0010299 / 0xC001029B), `msr-package:<p>` named
|
|
/// `package-<p>`. Each read on its lowest-numbered CPU, wrapping at 2^32
|
|
/// counts. Empty unless every domain's power unit reads, and none is zero.
|
|
fn msr_packages() -> Vec<Zone> {
|
|
let Some(cpuinfo) = text(PathBuf::from("/proc/cpuinfo")) else { return Vec::new() };
|
|
let value = |key: &str| {
|
|
cpuinfo.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim() == key).map(|(_, v)| v.trim().to_string()))
|
|
};
|
|
let (Some(vendor), Some(family)) = (value("vendor_id"), value("cpu family").and_then(|f| f.parse::<u32>().ok())) else {
|
|
return Vec::new();
|
|
};
|
|
let (unit_register, register, per_die) = match (vendor.as_str(), family) {
|
|
("GenuineIntel", 6) => (0x606, 0x611, true),
|
|
("AuthenticAMD" | "HygonGenuine", f) if f >= 0x17 => (0xC001_0299, 0xC001_029B, false),
|
|
_ => return Vec::new(),
|
|
};
|
|
let mut first = std::collections::BTreeMap::<(u64, Option<u64>), u64>::new();
|
|
for e in std::fs::read_dir("/sys/devices/system/cpu").into_iter().flatten().flatten() {
|
|
let name = e.file_name().to_string_lossy().into_owned();
|
|
let Some(cpu) = name.strip_prefix("cpu").and_then(|n| n.parse::<u64>().ok()) else { continue };
|
|
let Some(package) = number(e.path().join("topology/physical_package_id")) else { continue };
|
|
let die = per_die.then(|| number(e.path().join("topology/die_id")).unwrap_or(0));
|
|
first.entry((package, die)).and_modify(|c| *c = (*c).min(cpu)).or_insert(cpu);
|
|
}
|
|
let mut zones = Vec::new();
|
|
for ((package, die), cpu) in first {
|
|
let device = PathBuf::from(format!("/dev/cpu/{cpu}/msr"));
|
|
let Some(unit) = msr_read(&device, unit_register) else { return Vec::new() };
|
|
let shift = ((unit >> 8) & 0x1F) as u32;
|
|
if shift == 0 {
|
|
return Vec::new();
|
|
}
|
|
let (dir, name) = match die {
|
|
Some(d) => (format!("msr-package:{package}-die-{d}"), format!("package-{package}-die-{d}")),
|
|
None => (format!("msr-package:{package}"), format!("package-{package}")),
|
|
};
|
|
zones.push(Zone {
|
|
dir,
|
|
name: Some(name),
|
|
energy_uj: msr_read(&device, register).map(|raw| msr_uj(raw, shift)),
|
|
max_uj: Some(((1u128 << 32) * 1_000_000 / (1u128 << shift)) as u64),
|
|
msr: Some(Msr { device, register, shift }),
|
|
});
|
|
}
|
|
zones
|
|
}
|
|
|
|
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; and, where none of them is a
|
|
/// package, this host's package energy-status MSRs that read.
|
|
pub fn zones(root: &Path) -> Vec<Zone> {
|
|
let mut zones: Vec<Zone> = match std::fs::read_dir(root) {
|
|
Ok(entries) => 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")),
|
|
msr: None,
|
|
dir,
|
|
}
|
|
})
|
|
.collect(),
|
|
Err(_) => Vec::new(),
|
|
};
|
|
zones.sort_by(|a, b| a.dir.cmp(&b.dir));
|
|
if !zones.iter().any(is_package) {
|
|
zones.extend(msr_packages());
|
|
}
|
|
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 die of a multi-die package); or an MSR domain.
|
|
pub fn is_package(z: &Zone) -> bool {
|
|
let named = |n: &str| match n.strip_prefix("package-") {
|
|
Some(rest) => match rest.split_once("-die-") {
|
|
Some((p, d)) => digits(p) && digits(d),
|
|
None => digits(rest),
|
|
},
|
|
None => false,
|
|
};
|
|
z.msr.is_some() || (top_level(z) && z.name.as_deref().is_some_and(named))
|
|
}
|
|
|
|
/// 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` (an MSR package's count, at
|
|
/// its unit), in order; `None` if any fails.
|
|
pub fn read(root: &Path, dirs: &[&Zone]) -> Option<Vec<u64>> {
|
|
dirs.iter()
|
|
.map(|z| match &z.msr {
|
|
Some(m) => msr_read(&m.device, m.register).map(|raw| msr_uj(raw, m.shift)),
|
|
None => 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`, 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();
|
|
let none = |verdict| Watch { verdict, packages: names.clone(), intervals: 0, advances: Vec::new(), window: Duration::ZERO };
|
|
if packages.is_empty() {
|
|
let readable = all.iter().any(|z| top_level(z) && z.energy_uj.is_some());
|
|
return none(if readable { Verdict::NoPackage } else { Verdict::Unreadable });
|
|
}
|
|
let started = Instant::now();
|
|
let Some(mut prev) = read(root, &packages) else { return none(Verdict::Unreadable) };
|
|
let mut advances = vec![0u32; packages.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(root, &packages) {
|
|
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 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)).iter().filter(|z| z.msr.is_none()) {
|
|
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 status agrees with the oracle's own verdict, and RAPL is the meter's
|
|
/// source exactly when it is live.
|
|
#[test]
|
|
fn the_rapl_status_agrees_with_the_oracle_and_gates_the_source() {
|
|
let _one = serial();
|
|
let status = rapl_status();
|
|
let watch = oracle::watch(Path::new(oracle::POWERCAP));
|
|
let source = meter::meter_source();
|
|
eprintln!(
|
|
"rapl hardware: {}; oracle {}; status={status:?} source={source:?}",
|
|
oracle::listing(Path::new(oracle::POWERCAP)),
|
|
watch.summary()
|
|
);
|
|
assert_eq!(status == CounterStatus::Live, watch.verdict == Verdict::Live, "the status disagrees with the oracle");
|
|
match watch.verdict {
|
|
Verdict::Unreadable => assert_eq!(status, CounterStatus::Unavailable),
|
|
Verdict::NoPackage => assert_eq!(status, CounterStatus::Absent),
|
|
Verdict::Frozen => assert_eq!(status, CounterStatus::Frozen),
|
|
Verdict::Live => {}
|
|
}
|
|
assert_eq!(source == Some(MeterSource::Rapl), status == CounterStatus::Live, "selection disagrees with the status");
|
|
assert_eq!(meter::hardware_meter_available(), status == CounterStatus::Live);
|
|
}
|
|
|
|
/// Calls of [`ten_ms_of_work`] completed in this process.
|
|
static CALLS: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// About 10 ms of one thread's CPU work, counted.
|
|
fn ten_ms_of_work() {
|
|
let t = Instant::now();
|
|
let mut acc: u64 = 0;
|
|
while t.elapsed() < Duration::from_millis(10) {
|
|
for _ in 0..1_000 {
|
|
acc = std::hint::black_box(acc.wrapping_add(1));
|
|
}
|
|
}
|
|
CALLS.fetch_add(1, Ordering::SeqCst);
|
|
}
|
|
|
|
/// A burn on every spare core meters as a hardware figure of the CPU
|
|
/// package: `HwShunt`, from RAPL, claiming package energy and not the GPU.
|
|
/// The figure is a marginal per call (busy power over idle, times the busy
|
|
/// window, over the calls), so the calls' total can be no more than the
|
|
/// packages' energy over the whole measurement, which the oracle reads
|
|
/// around it; on a quiet host a figure that also summed a package's core
|
|
/// sub-zone (or psys, where psys tracks load) is about twice the busy margin
|
|
/// and overruns it. A figure of 0 is the crate's "inside the idle noise", and
|
|
/// is what a host whose other work holds the package at its power limit
|
|
/// reads. Where RAPL is not live there is no figure, and `work` never runs.
|
|
#[test]
|
|
fn an_all_core_burn_meters_inside_the_package_only_bracket() {
|
|
let _one = serial();
|
|
let all = oracle::zones(Path::new(oracle::POWERCAP));
|
|
let set = oracle::bracket_set(&all);
|
|
if meter::meter_source() != Some(MeterSource::Rapl) {
|
|
let before = CALLS.load(Ordering::SeqCst);
|
|
let m = meter::measure_marginal(ten_ms_of_work);
|
|
let watch = oracle::watch(Path::new(oracle::POWERCAP));
|
|
eprintln!("rapl burn: no RAPL source; figure {m:?}; oracle {}", watch.summary());
|
|
assert_eq!(m, None, "a figure with RAPL declined");
|
|
assert_eq!(CALLS.load(Ordering::SeqCst), before, "the work ran with RAPL declined");
|
|
assert_ne!(watch.verdict, Verdict::Live, "the oracle saw live packages, yet RAPL is not the source");
|
|
return;
|
|
}
|
|
let wall = Instant::now();
|
|
let c0 = CALLS.load(Ordering::SeqCst);
|
|
let o0 = oracle::read_all(&set);
|
|
let m = meter::measure_marginal(ten_ms_of_work).expect("a live RAPL source metered the burn");
|
|
let o3 = oracle::read_all(&set);
|
|
let calls = CALLS.load(Ordering::SeqCst) - c0;
|
|
let outer = oracle::energy(&set, &o0, &o3, oracle::is_package);
|
|
let core = oracle::energy(&set, &o0, &o3, oracle::is_core);
|
|
let psys = oracle::energy(&set, &o0, &o3, oracle::is_psys);
|
|
let total = m.uj * calls;
|
|
let energy = m.energy();
|
|
eprintln!(
|
|
"rapl burn: {calls} calls in {:?}; {} µJ a call, {total} µJ over the calls; oracle package-only {outer} µJ \
|
|
around the measurement (idle and busy), core {core} µJ, psys {psys} µJ; energy {energy:?}; domain {:?}",
|
|
wall.elapsed(),
|
|
m.uj,
|
|
m.source.domain()
|
|
);
|
|
assert_eq!(m.source, MeterSource::Rapl);
|
|
assert_eq!(energy.provenance, Provenance::HwShunt);
|
|
assert_eq!(energy.measured_uj, Some(m.uj));
|
|
let domain = m.source.domain();
|
|
assert!(domain.contains("package") && !domain.contains("GPU"), "the RAPL figure claims {domain}");
|
|
assert!(calls > 0, "the work never ran");
|
|
assert!(outer > 0, "the packages did not advance");
|
|
// Each call's figure is rounded to the microjoule: half a microjoule a call.
|
|
assert!(total <= outer + calls / 2 + 1, "{total} µJ over the calls overruns the packages' {outer} µJ");
|
|
if m.uj == 0 {
|
|
// The crate's contract: from a bracket, 0 means the busy window drew no
|
|
// more than the idle one. On a runner whose other jobs hold the package
|
|
// at its power limit through both windows, that is what it reads.
|
|
eprintln!("rapl burn: the busy window drew no more than the idle one (load {:?}): inside the idle noise", loadavg());
|
|
}
|
|
}
|
|
|
|
/// The host's load averages, for the log.
|
|
fn loadavg() -> Option<String> {
|
|
std::fs::read_to_string("/proc/loadavg").ok().map(|s| s.trim().to_string())
|
|
}
|