sandbox-meter: RAPL reads each domain's energy-status MSR where powercap lists none; die zones are packages #629
7 changed files with 1319 additions and 126 deletions
|
|
@ -6,7 +6,7 @@ Sandbox is the **isolated-execution substrate** of the Transaction Science open
|
|||
|
||||
Every other sandbox in the field isolates a **CPU process** — a container, a microVM, a Linux process pinned to a core. The accelerator, where present, is an opaque passthrough that nobody meters and nobody attests. But agentic and AI work does not run on a CPU. It runs across a **fabric** — CPU, GPU, NPU, TPU, photonic, quantum. So in this standard the unit of isolation is not a process; it is a **heterogeneous unit of work**, and the accelerator is a first-class citizen of the isolation boundary: a capability you grant, a resource you meter in joules, and a line item you attest.
|
||||
|
||||
A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads vendor energy counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap, NVIDIA's total-energy counter via NVML. A reading is tagged `HwShunt` only while its counter is demonstrably live: each vendor counter is used only if every rail of it that the meter sums into one figure keeps advancing while the meter's probe burns CPU — in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms — every IOReport CPU energy rail (the rail itself, one per die on a multi-die part), every RAPL package and every NVML GPU; and a span across which the CPU counter held is never a reading. A counter that is absent, or present but not live — frozen, or republished in lumps, as the IOReport CPU rails are on macOS 27 — yields no hardware-graded figure: the span degrades to the labeled `Estimator` (wall-clock × TDP), never a fabricated measurement, and `ioreport_status()`, `rapl_status()` and `nvml_status()` say why a host is estimated. For work on the meter's own process there is one more source, kept out of the default meter: on macOS, the CPU energy the kernel attributes to the process's own threads (`proc_pid_rusage`), read through `PowerMeter::detect_in_process()` under the same liveness rule, the reading thread taken off its core before each read and the read taken only once the thread's own energy count shows it was folded (a process's read of itself does not fold the thread reading, so a short span would otherwise read nothing, or energy spent before it; and a sleep does not always take a thread off its core, so the fold is checked, and a read whose thread was not seen to fold leaves the span estimated) — never for a subprocess, which it does not cover, nor whole for work that calls a system service, whose share the kernel bills to the process beside that count and this meter does not read; a single-process child's own figure is read from the exited child instead, a partial figure, since a process's exit does not fold its last time on a core into its count. For work in another live process of this user — a server, and its descendants — `TreeMeter` reads that same count for every process of its tree over a span, each end read once no thread of the tree is on a core and confirmed by a second walk that finds the tree's processes unchanged, and lists beside the figure every way it was seen to differ from the tree's energy over the span — short of it (a descendant reaped or exited in the span, a GPU client held, in-process Neural Engine memory, a member unreadable or still running at the stop, work a system service did for a member), or holding energy from outside the span (a member still running at the start; a bill for a service's work done before the span that was credited inside it; a member credited with service to other processes, which is subtracted, and need not be the span's own service, so the figure may be short of the span's energy or over it). Work a service does on a member's behalf, on a message that carries the member's voucher, is billed to the member by the kernel: the figure counts that billed CPU energy, less what members did for processes outside the tree, and lists it as delegated, because the bill over a span is not the span's delegated work: it carries none of the service's GPU or Neural Engine energy, none of its work on threads that never took the voucher, no credit landing after the span (the stop waits briefly for the billed counts to hold, which catches only a credit that lands within it), and it can hold a late credit for work done before the span — so the figure can be short of the delegated work or over it. Work the tree hands outside itself with no bill — over a socket, to a remote API — is not visible to any reader, so whether there is any is for the caller's operator to declare, never the reading's to claim; and a bill seen outweighs that declaration: such a span is partial whatever the operator said. Because a live counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced).
|
||||
A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads vendor energy counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap (or, on a kernel whose powercap lists no package zone, each package's energy-status MSR), NVIDIA's total-energy counter via NVML. A reading is tagged `HwShunt` only while its counter is demonstrably live: each vendor counter is used only if every rail of it that the meter sums into one figure keeps advancing while the meter's probe burns CPU — in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms — every IOReport CPU energy rail (the rail itself, one per die on a multi-die part), every RAPL package and every NVML GPU; and a span across which the CPU counter held is never a reading. A counter that is absent, or present but not live — frozen, or republished in lumps, as the IOReport CPU rails are on macOS 27 — yields no hardware-graded figure: the span degrades to the labeled `Estimator` (wall-clock × TDP), never a fabricated measurement, and `ioreport_status()`, `rapl_status()` and `nvml_status()` say why a host is estimated. For work on the meter's own process there is one more source, kept out of the default meter: on macOS, the CPU energy the kernel attributes to the process's own threads (`proc_pid_rusage`), read through `PowerMeter::detect_in_process()` under the same liveness rule, the reading thread taken off its core before each read and the read taken only once the thread's own energy count shows it was folded (a process's read of itself does not fold the thread reading, so a short span would otherwise read nothing, or energy spent before it; and a sleep does not always take a thread off its core, so the fold is checked, and a read whose thread was not seen to fold leaves the span estimated) — never for a subprocess, which it does not cover, nor whole for work that calls a system service, whose share the kernel bills to the process beside that count and this meter does not read; a single-process child's own figure is read from the exited child instead, a partial figure, since a process's exit does not fold its last time on a core into its count. For work in another live process of this user — a server, and its descendants — `TreeMeter` reads that same count for every process of its tree over a span, each end read once no thread of the tree is on a core and confirmed by a second walk that finds the tree's processes unchanged, and lists beside the figure every way it was seen to differ from the tree's energy over the span — short of it (a descendant reaped or exited in the span, a GPU client held, in-process Neural Engine memory, a member unreadable or still running at the stop, work a system service did for a member), or holding energy from outside the span (a member still running at the start; a bill for a service's work done before the span that was credited inside it; a member credited with service to other processes, which is subtracted, and need not be the span's own service, so the figure may be short of the span's energy or over it). Work a service does on a member's behalf, on a message that carries the member's voucher, is billed to the member by the kernel: the figure counts that billed CPU energy, less what members did for processes outside the tree, and lists it as delegated, because the bill over a span is not the span's delegated work: it carries none of the service's GPU or Neural Engine energy, none of its work on threads that never took the voucher, no credit landing after the span (the stop waits briefly for the billed counts to hold, which catches only a credit that lands within it), and it can hold a late credit for work done before the span — so the figure can be short of the delegated work or over it. Work the tree hands outside itself with no bill — over a socket, to a remote API — is not visible to any reader, so whether there is any is for the caller's operator to declare, never the reading's to claim; and a bill seen outweighs that declaration: such a span is partial whatever the operator said. Because a live counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced).
|
||||
|
||||
The substrate is intentionally **ownerless**: no protocol-level token, permissive licensing on spec text and reference code, and conformance vectors so any implementer can verify canonicity without a trust relationship with the authors. A conforming host runs a unit, enforces its grants, meters its energy, and seals its attestation **without contacting any single steward's servers as a condition of operation**. Transaction Science is one steward: it publishes the spec, ships a reference implementation, and operates commercial services around the substrate — not its proprietor.
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,13 @@ fn main() {
|
|||
"none — estimator fallback".into()
|
||||
}
|
||||
);
|
||||
// Linux: say whether RAPL reads, through which interface, and if not, why
|
||||
// (no powercap package zone and MSRs this process may not read, say).
|
||||
#[cfg(target_os = "linux")]
|
||||
match sandbox_meter::probe::RaplPackages::host() {
|
||||
Ok(p) => println!("rapl: {} over {}", p.source(), p.zones().join(", ")),
|
||||
Err(e) => println!("rapl: declined — {e}"),
|
||||
}
|
||||
|
||||
// Baseline: an idle span of the same length, so the workload's figure
|
||||
// has context. On this process's own count it is this process asleep;
|
||||
|
|
|
|||
|
|
@ -14,9 +14,13 @@
|
|||
//! M5 Max and an M3 Ultra, so those hosts get the estimator below, and
|
||||
//! [`ioreport_status`] says why.
|
||||
//! - **Linux:** Intel/AMD RAPL via powercap (`energy_uj`, wrap-corrected) —
|
||||
//! `HwShunt` — summing the zones whose `name` is `package-<n>` (never
|
||||
//! `psys`, which already includes them, or `dram`), **only when every
|
||||
//! package counter keeps advancing** ([`rapl_status`]); NVIDIA GPUs via NVML's total-energy counter when
|
||||
//! `HwShunt` — summing the zones whose `name` is `package-<n>` (or
|
||||
//! `package-<p>-die-<d>`, one per die of a multi-die Intel package; never
|
||||
//! `psys`, which already includes them, or `dram`), or, on a kernel whose
|
||||
//! powercap lists no package zone, each package's (on Intel, each die's)
|
||||
//! energy-status MSR through `/dev/cpu/<n>/msr` (`rapl-msr`; it needs the
|
||||
//! `msr` module and `CAP_SYS_RAWIO`), **only when every package counter
|
||||
//! keeps advancing** ([`rapl_status`]); NVIDIA GPUs via NVML's total-energy counter when
|
||||
//! `libnvidia-ml` is present at runtime — `HwShunt` — **only when every
|
||||
//! GPU's counter keeps advancing** ([`nvml_status`]). The rule is
|
||||
//! IOReport's: every rail a figure sums must keep advancing.
|
||||
|
|
@ -202,8 +206,9 @@ pub fn ioreport_status() -> IoReportStatus {
|
|||
pub enum CounterStatus {
|
||||
/// The counter could not be opened, or not read twice before the probe's
|
||||
/// cap: not this platform, no driver, no permission (RAPL's `energy_uj` is
|
||||
/// root-only on many kernels), or no device that supports it (for RAPL, no
|
||||
/// zone named `package-<n>`).
|
||||
/// root-only on many kernels, and its MSRs need `CAP_SYS_RAWIO`), or no
|
||||
/// device that supports it (for RAPL, no zone named `package-<n>` and no
|
||||
/// readable energy-status MSR).
|
||||
Unavailable,
|
||||
/// The counter opened and read, but not every rail it sums kept advancing
|
||||
/// while this process burned CPU: one held, or stepped only in a burst.
|
||||
|
|
@ -265,9 +270,13 @@ impl std::fmt::Display for CounterStatus {
|
|||
/// `name` is `package-<n>` must keep advancing while the probing thread burns
|
||||
/// CPU — the IOReport rule, [`CPU_RAIL_LIVENESS_BUDGET`] and its ~25 ms tick,
|
||||
/// applied to every package, since the probe sums them into one `Cpu` figure.
|
||||
/// Other zones (`psys`, `dram`) are neither summed nor probed. A host where
|
||||
/// powercap is absent or unreadable, or has no package zone, is `Unavailable`
|
||||
/// at once, with no burn. Always `Unavailable` off Linux.
|
||||
/// Other zones (`psys`, `dram`) are neither summed nor probed. Where powercap
|
||||
/// lists no package zone, the same rule runs over every package's (on Intel,
|
||||
/// every die's) energy-status MSR instead. A host whose package counters do
|
||||
/// not open — powercap unreadable, or no package zone and no readable MSR (no
|
||||
/// `msr` module, no `CAP_SYS_RAWIO`) — is `Unavailable` at once, with no burn,
|
||||
/// and the declined probe ([`RaplPackages::host`]) says why. Always
|
||||
/// `Unavailable` off Linux.
|
||||
pub fn rapl_status() -> CounterStatus {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
|
@ -1039,18 +1048,20 @@ mod tests {
|
|||
fn detect_selects_rapl_and_nvml_only_for_a_live_status() {
|
||||
use CounterStatus::{Frozen, Live, Unavailable};
|
||||
let sources = |rapl, nvml| PowerMeter::detect_given(Statuses { rapl, nvml, ..Statuses::NONE }).sources();
|
||||
// RAPL is `rapl-powercap`, or `rapl-msr` where powercap lists no package.
|
||||
let has_rapl = |s: &[&str]| s.iter().any(|n| n.starts_with("rapl-"));
|
||||
for status in [Unavailable, Frozen] {
|
||||
let s = sources(status, status);
|
||||
assert!(!s.contains(&"rapl-powercap") && !s.contains(&"nvml-total-energy"), "{status:?} selected {s:?}");
|
||||
assert!(!has_rapl(&s) && !s.contains(&"nvml-total-energy"), "{status:?} selected {s:?}");
|
||||
let s = sources(Live, status);
|
||||
assert!(!s.contains(&"nvml-total-energy"), "NVML {status:?} selected: {s:?}");
|
||||
let s = sources(status, Live);
|
||||
assert!(!s.contains(&"rapl-powercap"), "RAPL {status:?} selected: {s:?}");
|
||||
assert!(!has_rapl(&s), "RAPL {status:?} selected: {s:?}");
|
||||
}
|
||||
let live = sources(Live, Live);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
assert_eq!(live.contains(&"rapl-powercap"), rapl::RaplProbe::open().is_ok(), "{live:?}");
|
||||
assert_eq!(has_rapl(&live), rapl::RaplProbe::open().is_ok(), "{live:?}");
|
||||
assert_eq!(live.contains(&"nvml-total-energy"), nvml::NvmlProbe::open().is_ok(), "{live:?}");
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,7 +20,8 @@ fn the_counter_liveness_probes_run_once_per_process() {
|
|||
std::thread::spawn(move || {
|
||||
let selects = || {
|
||||
let s = PowerMeter::detect().sources();
|
||||
(s.contains(&"rapl-powercap"), s.contains(&"nvml-total-energy"))
|
||||
// `rapl-powercap`, or `rapl-msr` where powercap lists no package.
|
||||
(s.iter().any(|n| n.starts_with("rapl-")), s.contains(&"nvml-total-energy"))
|
||||
};
|
||||
start.wait();
|
||||
if i % 2 == 0 {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,18 @@
|
|||
//! 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
|
||||
//! applies the sustained-advance rule to the top-level `package-<n>` (and
|
||||
//! `package-<p>-die-<d>`) 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, and a
|
||||
//! span over a CPU burn must be one `Cpu` record, `HwShunt`, whose figure
|
||||
//! sits inside the oracle's package-only bracket and clearly below
|
||||
//! package+psys and package+core.
|
||||
//! package+psys and package+core. A host whose powercap lists no package zone
|
||||
//! and whose meter reads the energy-status MSRs instead is checked by
|
||||
//! `tests/rapl_msr_hardware.rs`, against an oracle of the MSRs; here each test
|
||||
//! asserts only that this is that case.
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
|
|
@ -119,9 +123,17 @@ mod oracle {
|
|||
z.dir.strip_prefix("intel-rapl:").is_some_and(digits)
|
||||
}
|
||||
|
||||
/// A package zone: top-level, named `package-<n>`.
|
||||
/// A package zone: top-level, named `package-<n>`, or `package-<p>-die-<d>`
|
||||
/// (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(digits)
|
||||
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,
|
||||
};
|
||||
top_level(z) && z.name.as_deref().is_some_and(named)
|
||||
}
|
||||
|
||||
/// The platform zone.
|
||||
|
|
@ -374,6 +386,22 @@ mod oracle {
|
|||
|
||||
use oracle::Verdict;
|
||||
|
||||
/// Whether this host's RAPL counter is read from the energy-status MSRs:
|
||||
/// powercap lists no package zone, and the meter's RAPL probe is `rapl-msr`.
|
||||
/// Such a host's counters are checked against an independent reading of the
|
||||
/// MSRs in `tests/rapl_msr_hardware.rs`; the powercap oracle here has nothing
|
||||
/// to read. It never holds where powercap lists a package zone.
|
||||
fn reads_the_msrs(sources: &[&str]) -> bool {
|
||||
let msr = sources.contains(&"rapl-msr");
|
||||
let listed = oracle::zones(Path::new(oracle::POWERCAP)).iter().any(oracle::is_package);
|
||||
assert!(!(msr && listed), "the meter reads the MSRs although powercap lists a package zone: {sources:?}");
|
||||
if msr {
|
||||
eprintln!("rapl hardware: powercap lists no package zone and the meter reads the MSRs ({sources:?}); see rapl_msr_hardware");
|
||||
assert_eq!(rapl_status(), CounterStatus::Live, "a RAPL probe in the meter, yet RAPL is not live");
|
||||
}
|
||||
msr
|
||||
}
|
||||
|
||||
/// The status agrees with the oracle's own verdict, and only a live status
|
||||
/// opens the package counters and puts the RAPL probe in the meter.
|
||||
#[test]
|
||||
|
|
@ -383,6 +411,9 @@ fn the_rapl_status_agrees_with_the_oracle_and_gates_the_probe() {
|
|||
let watch = oracle::watch(Path::new(oracle::POWERCAP));
|
||||
let opened = RaplPackages::host().map(|p| p.zones().iter().map(|z| z.to_string()).collect::<Vec<_>>());
|
||||
let sources = PowerMeter::detect().sources();
|
||||
if reads_the_msrs(&sources) {
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"rapl hardware: {}; oracle {}; status={status:?} opened={opened:?} sources={sources:?}",
|
||||
oracle::listing(Path::new(oracle::POWERCAP)),
|
||||
|
|
@ -409,6 +440,9 @@ fn the_rapl_status_agrees_with_the_oracle_and_gates_the_probe() {
|
|||
fn a_cpu_burn_is_one_hwshunt_cpu_record_inside_the_package_only_bracket() {
|
||||
let _one = serial();
|
||||
let mut meter = PowerMeter::detect();
|
||||
if reads_the_msrs(&meter.sources()) {
|
||||
return;
|
||||
}
|
||||
if !meter.sources().contains(&"rapl-powercap") {
|
||||
let watch = oracle::watch(Path::new(oracle::POWERCAP));
|
||||
eprintln!("rapl burn: no RAPL probe in {:?}; oracle {}", meter.sources(), watch.summary());
|
||||
|
|
@ -463,6 +497,9 @@ fn the_package_counters_read_inside_the_package_only_bracket() {
|
|||
assert_ne!(oracle::watch(Path::new(oracle::POWERCAP)).verdict, Verdict::Live);
|
||||
return;
|
||||
};
|
||||
if reads_the_msrs(&[packages.source()]) {
|
||||
return;
|
||||
}
|
||||
let all = oracle::zones(Path::new(oracle::POWERCAP));
|
||||
let mut cpu = Duration::ZERO;
|
||||
let (b, m0, m1) = oracle::around(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,360 @@
|
|||
//! Linux RAPL through the energy-status MSRs, against the hardware: on a host
|
||||
//! whose powercap lists no package zone, the meter's `rapl-msr` probe, 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 the MSRs itself — `/proc/cpuinfo`'s
|
||||
//! vendor and family, each CPU's `topology/physical_package_id` (and `die_id`
|
||||
//! on Intel), and each domain's power-unit and energy-status MSR through
|
||||
//! `/dev/cpu/<n>/msr` — and applies the sustained-advance rule on its own.
|
||||
//! None of these tests passes without asserting: where powercap lists a zone
|
||||
//! that is or may be a package, the meter must not read the MSRs; where the
|
||||
//! MSRs cannot be read (no `msr` module, no `CAP_SYS_RAWIO`, a CI container),
|
||||
//! RAPL must not be live and the declined probe must say why; where they read,
|
||||
//! the status must agree with the oracle, and a span over a CPU burn must be
|
||||
//! one `Cpu` record, `HwShunt`, from `rapl-msr`, whose figure sits inside the
|
||||
//! oracle's bracket. Each test prints one `rapl msr:` line.
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use sandbox_meter::probe::{rapl_status, CounterStatus, PowerMeter, RaplPackages};
|
||||
use sandbox_meter::Provenance;
|
||||
use sandbox_schema::DeviceClass;
|
||||
|
||||
/// 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 the RAPL MSRs, read here and nowhere else, and
|
||||
/// the sustained-advance verdict over them by the family's rule. Nothing in it
|
||||
/// comes from the crate under test.
|
||||
mod oracle {
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::raw::{c_int, c_long};
|
||||
use std::os::unix::fs::FileExt;
|
||||
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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn digits(s: &str) -> bool {
|
||||
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Whether powercap lists a top-level `intel-rapl:<n>` zone that is a
|
||||
/// package (`name` reads `package-<n>`, or `package-<p>-die-<d>` for a die
|
||||
/// of a multi-die package) or may be one (`name` does not read): the meter
|
||||
/// then reads powercap or nothing, never the MSRs.
|
||||
pub fn powercap_may_list_a_package() -> bool {
|
||||
let named = |n: &str| match n.trim().strip_prefix("package-") {
|
||||
Some(rest) => match rest.split_once("-die-") {
|
||||
Some((p, d)) => digits(p) && digits(d),
|
||||
None => digits(rest),
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(POWERCAP) else { return false };
|
||||
entries.flatten().any(|e| {
|
||||
let dir = e.file_name().to_string_lossy().into_owned();
|
||||
dir.strip_prefix("intel-rapl:").is_some_and(digits)
|
||||
&& match std::fs::read_to_string(e.path().join("name")) {
|
||||
Ok(name) => named(&name),
|
||||
Err(_) => true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One RAPL energy domain as the oracle reads it.
|
||||
#[derive(Debug)]
|
||||
pub struct Domain {
|
||||
/// `package-<p>` (AMD, Hygon) or `package-<p>-die-<d>` (Intel).
|
||||
pub name: String,
|
||||
pub device: PathBuf,
|
||||
pub register: u64,
|
||||
/// 2^-`shift` J per count.
|
||||
pub shift: u32,
|
||||
}
|
||||
|
||||
fn msr(device: &Path, register: u64) -> std::io::Result<u64> {
|
||||
let mut raw = [0u8; 8];
|
||||
std::fs::File::open(device)?.read_exact_at(&mut raw, register)?;
|
||||
Ok(u64::from_le_bytes(raw))
|
||||
}
|
||||
|
||||
fn number(p: PathBuf) -> Result<u32, String> {
|
||||
let s = std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?;
|
||||
s.trim().parse().map_err(|e| format!("{}: {e}", p.display()))
|
||||
}
|
||||
|
||||
/// This host's RAPL domains through its MSRs: Intel family 6 per die of
|
||||
/// each package, AMD Zen and Hygon per package, each through the first of
|
||||
/// its CPUs whose device reads both MSRs. `Err` when any of that does not
|
||||
/// read, or a unit is zero.
|
||||
pub fn domains() -> Result<Vec<Domain>, String> {
|
||||
let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").map_err(|e| format!("/proc/cpuinfo: {e}"))?;
|
||||
let field = |key: &str| {
|
||||
cpuinfo.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim() == key).map(|(_, v)| v.trim()))
|
||||
};
|
||||
let (vendor, family) = (field("vendor_id"), field("cpu family").and_then(|f| f.parse::<u32>().ok()));
|
||||
let (unit_reg, energy_reg, per_die) = match (vendor, family) {
|
||||
(Some("GenuineIntel"), Some(6)) => (0x606, 0x611, true),
|
||||
(Some("AuthenticAMD" | "HygonGenuine"), Some(f)) if f >= 0x17 => (0xC001_0299, 0xC001_029B, false),
|
||||
other => return Err(format!("no known RAPL MSRs on {other:?}")),
|
||||
};
|
||||
let mut by: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
|
||||
for e in std::fs::read_dir("/sys/devices/system/cpu").map_err(|e| e.to_string())?.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let Some(cpu) = name.strip_prefix("cpu").filter(|n| digits(n)).and_then(|n| n.parse::<u32>().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let t = e.path().join("topology");
|
||||
let package = number(t.join("physical_package_id"))?;
|
||||
let die = if per_die { number(t.join("die_id"))? } else { 0 };
|
||||
by.entry((package, die)).or_default().push(cpu);
|
||||
}
|
||||
if by.is_empty() {
|
||||
return Err("no CPU listed".into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for ((package, die), mut cpus) in by {
|
||||
cpus.sort_unstable();
|
||||
let name = if per_die { format!("package-{package}-die-{die}") } else { format!("package-{package}") };
|
||||
let mut first = None;
|
||||
let found = cpus.iter().find_map(|cpu| {
|
||||
let device = PathBuf::from(format!("/dev/cpu/{cpu}/msr"));
|
||||
match msr(&device, unit_reg).and_then(|u| msr(&device, energy_reg).map(|_| u)) {
|
||||
Ok(unit) => Some((device, unit)),
|
||||
Err(e) => {
|
||||
first.get_or_insert(format!("{}: {e}", device.display()));
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some((device, unit)) = found else {
|
||||
return Err(format!("{name}: no CPU's MSRs read (first {})", first.unwrap_or_default()));
|
||||
};
|
||||
let shift = ((unit >> 8) & 0x1f) as u32;
|
||||
if shift == 0 {
|
||||
return Err(format!("{name}: a zero energy unit"));
|
||||
}
|
||||
out.push(Domain { name, device, register: energy_reg, shift });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every domain's raw 32-bit count, in order; `None` if one fails.
|
||||
pub fn read(domains: &[Domain]) -> Option<Vec<u32>> {
|
||||
domains.iter().map(|d| msr(&d.device, d.register).ok().map(|raw| (raw & 0xffff_ffff) as u32)).collect()
|
||||
}
|
||||
|
||||
/// The domains' energy between two reads, in µJ: each count's rise modulo
|
||||
/// 2^32 at its own unit, summed, rounded down.
|
||||
pub fn energy(domains: &[Domain], before: &[u32], after: &[u32]) -> u64 {
|
||||
domains
|
||||
.iter()
|
||||
.zip(before.iter().zip(after))
|
||||
.map(|(d, (&b, &a))| ((u128::from(a.wrapping_sub(b)) * 1_000_000) >> d.shift) as u64)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The sustained-advance run over the domains: sample every count,
|
||||
/// burning 25 ms of this thread's CPU (capped at 500 ms of wall time)
|
||||
/// between samples, until every domain 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.
|
||||
/// Whether it ended live, and each domain's advances out of the intervals.
|
||||
pub fn watch(domains: &[Domain]) -> (bool, Vec<u32>, u32) {
|
||||
let started = Instant::now();
|
||||
let Some(mut prev) = read(domains) else { return (false, Vec::new(), 0) };
|
||||
let mut advances = vec![0u32; domains.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(domains) {
|
||||
intervals += 1;
|
||||
for ((a, n), p) in advances.iter_mut().zip(&now).zip(&prev) {
|
||||
*a += u32::from(n > p); // a wrap reads as a fall, which is no advance
|
||||
|
||||
}
|
||||
prev = now;
|
||||
}
|
||||
}
|
||||
(live(&advances, intervals, started.elapsed()), advances, intervals)
|
||||
}
|
||||
}
|
||||
|
||||
/// The RAPL source names in a meter's sources.
|
||||
fn rapl_sources<'a>(sources: &[&'a str]) -> Vec<&'a str> {
|
||||
sources.iter().copied().filter(|s| s.starts_with("rapl-")).collect()
|
||||
}
|
||||
|
||||
/// Where powercap lists a zone that is or may be a package, the meter reads
|
||||
/// powercap or nothing, never the MSRs. Prints and returns `true` then.
|
||||
fn powercap_is_read(sources: &[&str]) -> bool {
|
||||
if !oracle::powercap_may_list_a_package() {
|
||||
return false;
|
||||
}
|
||||
eprintln!("rapl msr: powercap lists a package zone (or one that may be), so the MSRs are not read; sources {sources:?}");
|
||||
assert!(!sources.contains(&"rapl-msr"), "the meter read the MSRs beside a powercap package zone: {sources:?}");
|
||||
true
|
||||
}
|
||||
|
||||
/// The status agrees with the oracle's own reading of the MSRs, only a live
|
||||
/// status opens them and puts `rapl-msr` in the meter, and where they do not
|
||||
/// read the declined probe says why.
|
||||
#[test]
|
||||
fn the_msr_status_agrees_with_the_oracle_and_gates_the_probe() {
|
||||
let _one = serial();
|
||||
let status = rapl_status();
|
||||
let sources = PowerMeter::detect().sources();
|
||||
if powercap_is_read(&sources) {
|
||||
return;
|
||||
}
|
||||
let opened = RaplPackages::host();
|
||||
match oracle::domains() {
|
||||
Err(why) => {
|
||||
let declined = opened.as_ref().err().map(|e| e.to_string()).unwrap_or_default();
|
||||
eprintln!("rapl msr: the oracle reads no MSR ({why}); status {status:?}; declined: {declined}");
|
||||
assert_ne!(status, CounterStatus::Live, "RAPL live although the oracle reads no MSR");
|
||||
assert!(rapl_sources(&sources).is_empty(), "{sources:?}");
|
||||
assert!(opened.is_err(), "the package counters opened although the oracle reads no MSR");
|
||||
if status == CounterStatus::Unavailable {
|
||||
assert!(declined.contains("energy-status MSRs"), "the declined probe does not say why: {declined}");
|
||||
}
|
||||
}
|
||||
Ok(domains) => {
|
||||
let names: Vec<&str> = domains.iter().map(|d| d.name.as_str()).collect();
|
||||
let (live, advances, intervals) = oracle::watch(&domains);
|
||||
eprintln!(
|
||||
"rapl msr: oracle domains {names:?} live={live} advances {advances:?} of {intervals}; status {status:?}; \
|
||||
sources {sources:?}"
|
||||
);
|
||||
assert_eq!(status == CounterStatus::Live, live, "the status disagrees with the oracle");
|
||||
match opened {
|
||||
Ok(p) => {
|
||||
assert!(live, "opened without the oracle seeing every domain live");
|
||||
assert_eq!((p.source(), p.zones()), ("rapl-msr", names), "the probe holds other domains than the oracle's");
|
||||
assert_eq!(rapl_sources(&sources), ["rapl-msr"]);
|
||||
}
|
||||
Err(e) => {
|
||||
assert!(!live, "live, yet the package counters did not open: {e}");
|
||||
assert!(rapl_sources(&sources).is_empty(), "{sources:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Over a 300 ms one-thread CPU burn, a span of a meter whose RAPL probe reads
|
||||
/// the MSRs is one `Cpu` record, `HwShunt`, from `rapl-msr`, its figure inside
|
||||
/// the oracle's bracket of the same MSRs read around the meter's own reads; and
|
||||
/// the package counters read through `RaplPackages` sit inside it too. Where
|
||||
/// the MSRs are not live, no span has a RAPL figure.
|
||||
#[test]
|
||||
fn a_cpu_burn_through_the_msrs_reads_inside_the_oracles_bracket() {
|
||||
let _one = serial();
|
||||
let mut meter = PowerMeter::detect();
|
||||
if powercap_is_read(&meter.sources()) {
|
||||
return;
|
||||
}
|
||||
if !meter.sources().contains(&"rapl-msr") {
|
||||
eprintln!("rapl msr: no MSR probe in {:?}; status {:?}", meter.sources(), rapl_status());
|
||||
assert_ne!(rapl_status(), CounterStatus::Live);
|
||||
let ((), m) = meter.measure(|| {
|
||||
oracle::burn(Duration::from_millis(50), Duration::from_secs(5));
|
||||
});
|
||||
assert!(
|
||||
!m.devices.iter().any(|d| d.class == DeviceClass::Cpu && d.provenance == Provenance::HwShunt),
|
||||
"a CPU record was graded HwShunt with RAPL declined: {:?}",
|
||||
m.devices
|
||||
);
|
||||
return;
|
||||
}
|
||||
let domains = oracle::domains().expect("the meter reads the MSRs, so the oracle can");
|
||||
let slack = domains.len() as u64; // each domain's figure is floored once per read
|
||||
let read = || oracle::read(&domains).expect("an oracle read of the MSRs");
|
||||
|
||||
let o0 = read();
|
||||
let span = meter.start();
|
||||
let o1 = read();
|
||||
let cpu = oracle::burn(Duration::from_millis(300), Duration::from_secs(10));
|
||||
let o2 = read();
|
||||
let m = span.stop(&mut meter);
|
||||
let o3 = read();
|
||||
let (inner, outer) = (oracle::energy(&domains, &o1, &o2), oracle::energy(&domains, &o0, &o3));
|
||||
let records: Vec<_> = m.devices.iter().filter(|d| d.class == DeviceClass::Cpu).collect();
|
||||
assert_eq!(records.len(), 1, "not one CPU record: {:?}", m.devices);
|
||||
let d = records[0];
|
||||
assert_eq!((d.provenance, m.source), (Provenance::HwShunt, "rapl-msr"), "{d:?}");
|
||||
let uj = d.measured_uj.expect("an HwShunt record carries its measured figure");
|
||||
|
||||
let mut packages = RaplPackages::host().expect("the meter has the MSR probe, so the package counters open");
|
||||
let p0 = read();
|
||||
let c0 = packages.cumulative_uj().expect("live MSRs read");
|
||||
let p1 = read();
|
||||
oracle::burn(Duration::from_millis(300), Duration::from_secs(10));
|
||||
let p2 = read();
|
||||
let c1 = packages.cumulative_uj().expect("live MSRs read");
|
||||
let p3 = read();
|
||||
let (p_inner, p_outer) = (oracle::energy(&domains, &p1, &p2), oracle::energy(&domains, &p0, &p3));
|
||||
eprintln!(
|
||||
"rapl msr: {cpu:?} of thread CPU; span {uj} µJ (oracle inner {inner}, outer {outer}); packages {} µJ (oracle \
|
||||
inner {p_inner}, outer {p_outer}); domains {:?}",
|
||||
c1 - c0,
|
||||
packages.zones()
|
||||
);
|
||||
assert!(inner > 0, "the MSRs did not advance over the burn");
|
||||
assert!(inner.saturating_sub(slack) <= uj && uj <= outer + slack, "the span is outside the oracle's bracket");
|
||||
assert!(
|
||||
p_inner.saturating_sub(slack) <= c1 - c0 && c1 - c0 <= p_outer + slack,
|
||||
"the package counters are outside the oracle's bracket"
|
||||
);
|
||||
assert!(cpu >= Duration::from_millis(300), "the burn got only {cpu:?} of CPU");
|
||||
}
|
||||
Loading…
Reference in a new issue