From 6a80a0cf4312943c0b8891c6c1e9f0405dc83584 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 4 Nov 2026 21:12:32 -0500 Subject: [PATCH 1/2] sandbox-meter: RAPL reads each package's energy-status MSR where powercap lists none Some kernels ship without the powercap RAPL driver (Ubuntu without linux-modules-extra, as on some rented bare-metal servers), so /sys/class/powercap lists no package while the counters are there, and the meter estimated hosts that can measure. Where powercap lists no package zone, the RAPL probe now reads the same counters directly, as `rapl-msr`: each domain's MSR_PKG_ENERGY_STATUS (0x611, Intel family 6; 0xC001029B, AMD Zen and Hygon) through /dev/cpu//msr, the 32-bit count converted at the domain's own unit (bits 12:8 of the power-unit MSR, 0x606 or 0xC0010299), so it wraps at 2^32 counts in microjoules. A domain is a package on AMD and Hygon and a die of a package on Intel, as sandbox-exec-microvm's MSR meter has it, read through the first of its CPUs whose MSRs read. Every domain or none: a CPU with no readable topology (offline), a domain whose MSRs read through none of its CPUs, or a zero unit (KVM guests read zeros) opens nothing. Powercap is still preferred, and a tree that lists a package zone, or a zone that may be one, is read through powercap or not at all. The liveness rule is unchanged: every domain readable and live, or no figure. A declined probe now says why its counters did not open: the probe run records the reason, and RaplPackages::host() / RaplProbe::given append it to the gate's text. Without CAP_SYS_RAWIO: "no RAPL package zone in /sys/class/powercap; and the energy-status MSRs: package-0-die-0: the RAPL MSRs read through none of its CPUs (16 tried; first /dev/cpu/0/msr: Permission denied (os error 13)); reading an MSR needs the msr module, CAP_SYS_RAWIO and read access to /dev/cpu//msr". joule-meter prints the RAPL interface it reads, or that reason. RaplPackages gains live_at (a fake host: powercap tree plus filesystem root) and source(); live_in stays powercap-only. On two bare-metal servers with no powercap zone (msr module loaded), as root: a one-socket Xeon E-2278G live (package-0-die-0, 12 of 12 intervals), a 300 ms one-thread burn 8.85 J inside the oracle's bracket (8.849 to 8.877 J), joule-meter 2.04 W idle and 122.3 W on all cores; two Xeon Silver 4215R sockets live (package-0-die-0 and package-1-die-0), the burn 23.07 J (76.9 W) inside the bracket, joule-meter 26.8 W idle and 207.1 W on all cores. As an unprivileged user the probe declines with the reason above and the meter estimates. Tests: fake roots (a register's bytes at its offset in a fake device file) cover a two-socket Intel host, the 32-bit wrap, AMD's registers and per-package scope, an Intel package of two dies, a domain read through its second CPU, a zero unit, an unknown CPU, a missing device, an offline CPU, a missing die_id, no CPU, powercap preferred (and an unreadable powercap package not falling through), the public counter and the liveness run over MSR domains, and the declined probe's reason. A new hardware test, tests/rapl_msr_hardware.rs, checks the MSR path against an oracle of its own that reads the MSRs; the powercap hardware test hands such a host to it. Unit and hardware tests pass on Linux x86_64 (powercap host) and on both servers above as root and unprivileged; the macOS CI step passes (both greps), and clippy is clean on macOS (sandbox-meter, sandbox-proof) and on Linux apart from three existing type_complexity errors in tests/nvml_hardware.rs. Co-Authored-By: Claude Opus 5.5 --- sandbox/README.md | 2 +- .../sandbox-meter/src/bin/joule_meter.rs | 7 + .../crates/sandbox-meter/src/probe/mod.rs | 30 +- .../crates/sandbox-meter/src/probe/rapl.rs | 885 ++++++++++++++++-- .../tests/counter_probes_once_per_process.rs | 3 +- .../sandbox-meter/tests/rapl_hardware.rs | 30 +- .../sandbox-meter/tests/rapl_msr_hardware.rs | 352 +++++++ 7 files changed, 1199 insertions(+), 110 deletions(-) create mode 100644 sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs diff --git a/sandbox/README.md b/sandbox/README.md index fa08edb68..1e765c611 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -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. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/bin/joule_meter.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/bin/joule_meter.rs index 407a9d2ac..5a6bd65f0 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/bin/joule_meter.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/bin/joule_meter.rs @@ -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; diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs index d326d8235..a28597acd 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs @@ -15,8 +15,11 @@ //! [`ioreport_status`] says why. //! - **Linux:** Intel/AMD RAPL via powercap (`energy_uj`, wrap-corrected) — //! `HwShunt` — summing the zones whose `name` is `package-` (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 +//! `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//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 +205,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-`). + /// root-only on many kernels, and its MSRs need `CAP_SYS_RAWIO`), or no + /// device that supports it (for RAPL, no zone named `package-` 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 +269,13 @@ impl std::fmt::Display for CounterStatus { /// `name` is `package-` 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 +1047,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"))] diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs index e7e3d68bf..d41dc819b 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs @@ -1,7 +1,8 @@ -//! Intel/AMD RAPL via the Linux powercap interface — package-domain -//! cumulative energy from `/sys/class/powercap/intel-rapl:N/energy_uj`, for -//! every top-level zone whose `name` is `package-`, each wrap-corrected -//! against its own `max_energy_range_uj`. `HwShunt` provenance. +//! Intel/AMD RAPL — package-domain cumulative energy, through the Linux +//! powercap interface (`/sys/class/powercap/intel-rapl:N/energy_uj`, for every +//! top-level zone whose `name` is `package-`), or, on a kernel that lists no +//! package zone, from each domain's energy-status MSR. Each domain is +//! wrap-corrected against its own range. `HwShunt` provenance. //! //! **The package zones and no other.** A top-level powercap zone is not //! necessarily a package. Many Intel hosts expose `psys`, the platform zone, @@ -16,57 +17,157 @@ //! read (it may be a package), does not open the probe, rather than leave a //! package out of a figure that claims the CPU. //! +//! **Without the powercap driver.** Some kernels ship without `intel_rapl_msr` +//! (Ubuntu without `linux-modules-extra`, as on some rented bare-metal +//! servers), so powercap lists no package zone while the counters are there. +//! Where powercap lists none, the probe reads the same counters directly (the +//! [`Probe`] is then `rapl-msr`): each domain's energy-status MSR +//! (`MSR_PKG_ENERGY_STATUS`, 0x611, on Intel family 6; `0xC001029B` on AMD Zen +//! and Hygon), at the domain's own energy-status unit (bits 12:8 of the +//! power-unit MSR, 0x606 or `0xC0010299`), through the `msr` driver's device +//! file of one of its CPUs (`/dev/cpu//msr`, the register number as the file +//! offset; this needs the `msr` module, `CAP_SYS_RAWIO` and read access to the +//! file). A domain is a package on AMD and Hygon, and a die of a package on +//! Intel, whose kernels scope RAPL per die (the same thing on a single-die +//! part), as sandbox-exec-microvm's MSR meter does. The 32-bit count wraps at +//! 2^32 counts, about 262 kJ at the common unit of 2^-14 J, and resolves one +//! count (about 61 µJ there). Every domain or none, as for powercap: the probe +//! does not open when a CPU has no readable topology (an offline CPU's package +//! is unknown, and could be one no online CPU covers), when a domain's MSRs +//! read through none of its CPUs, or when a power-unit MSR reads a zero unit +//! (some hypervisors let a guest read these MSRs and return zeros). Powercap is +//! preferred: a tree that lists a package zone is read through powercap or not +//! at all. +//! //! **Used only when every package counter is live.** A readable `energy_uj` //! is not yet a reading of the work: a counter that holds, or that is //! republished in rare lumps, would seal zero or a stale refresh as the energy //! of a span. So [`RaplProbe::given`] declines unless //! [`rapl_status`](super::rapl_status) is `Live` — every package zone's raw -//! `energy_uj` advanced in at least five sample intervals, at least four in -//! five of those observed, over at least 300 ms, while the probing thread -//! burned CPU — and the meter falls back to its estimator. Every package must +//! `energy_uj` (or every MSR domain's count, in microjoules) advanced in at +//! least five sample intervals, at least four in five of those observed, over +//! at least 300 ms, while the probing thread burned CPU — and the meter falls +//! back to its estimator. Where the counters did not open, it says why: an MSR +//! host read without `CAP_SYS_RAWIO`, say, names the device that refused. Every package must //! be live, not one: the probe sums them into one `Cpu` figure, and a package //! whose counter held is a domain that figure would claim without reading it. //! Only the package zones are probed, as only they are summed: a live `psys` //! beside a held package does not make the counter live. The rule and the run //! live in [`liveness`](super::liveness) ([`counter_run`]); this module only -//! opens the package zones and reads their raw integers. +//! opens the package domains and reads their raw integers. //! //! Built everywhere: the zone listing, the reads and the run take the -//! powercap root as a path, so the tests, and a caller's -//! [`RaplPackages::live_in`], run them on fake trees on any platform. Only -//! Linux opens the sysfs tree ([`RaplPackages::host`], the meter's probe). +//! powercap root (and, for the MSRs, the filesystem root) as a path, so the +//! tests, and a caller's [`RaplPackages::live_in`] or +//! [`RaplPackages::live_at`], run them on fake trees on any platform (the MSR +//! reads on any Unix). Only Linux opens the host's own ([`RaplPackages::host`], +//! the meter's probe). //! [`RaplPackages`] is this probe for a caller outside the crate that takes //! its own before/after differences. use super::liveness::{counter_run, counter_status_of, gated, RailWatch, Sample}; #[cfg(target_os = "linux")] use super::liveness::{counter_tick, PROBE_TICK}; -#[cfg(target_os = "linux")] use super::CounterStatus; use super::{Probe, ProbeError, CPU_RAIL_LIVENESS_BUDGET}; use sandbox_schema::DeviceClass; +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; #[cfg(target_os = "linux")] use std::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(target_os = "linux")] +use std::sync::OnceLock; use std::time::Instant; /// The powercap tree the production probe reads. #[cfg(target_os = "linux")] const POWERCAP: &str = "/sys/class/powercap"; +/// The filesystem root the production probe reads the MSRs under, where +/// powercap lists no package zone. +#[cfg(target_os = "linux")] +const HOST_ROOT: &str = "/"; + +/// Intel's power-unit MSR (family 6). +const MSR_RAPL_POWER_UNIT: u64 = 0x606; +/// Intel's package energy-status MSR (family 6). +const MSR_PKG_ENERGY_STATUS: u64 = 0x611; +/// AMD Zen's (and Hygon's) power-unit MSR. +const MSR_AMD_RAPL_POWER_UNIT: u64 = 0xC001_0299; +/// AMD Zen's (and Hygon's) package energy-status MSR. +const MSR_AMD_PKG_ENERGY_STATUS: u64 = 0xC001_029B; + +/// What reading an MSR takes, for a domain whose MSRs read through none of +/// its CPUs. +const MSR_ACCESS: &str = "reading an MSR needs the msr module, CAP_SYS_RAWIO and read access to /dev/cpu//msr"; + struct Domain { - /// The package zone's directory name, `intel-rapl:N`: its rail name for - /// the liveness rule. + /// Its rail name for the liveness rule: the package zone's directory name, + /// `intel-rapl:N`; for an MSR domain, `package-

` (AMD, Hygon) or + /// `package-

-die-` (Intel). name: String, - energy_path: PathBuf, - /// The value this zone's counter wraps at, read at open: non-zero. + /// Where its energy is read. + source: Source, + /// The value this domain's microjoule figure wraps at, read at open: + /// non-zero. For an MSR domain, 2^32 counts at its unit. max_range_uj: u64, last_raw: u64, /// Accumulated energy since probe creation, wrap-corrected. accumulated_uj: u64, } +/// Where a package domain's energy is read. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Source { + /// A powercap zone's `energy_uj`. + Powercap(PathBuf), + /// An energy-status MSR. + Msr(Msr), +} + +/// A domain's energy-status MSR, read through the `msr` driver's device file +/// of one of its CPUs. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Msr { + /// The device file, `/dev/cpu//msr`: the register number is the + /// file offset. + device: PathBuf, + /// The energy-status MSR: [`MSR_PKG_ENERGY_STATUS`] or + /// [`MSR_AMD_PKG_ENERGY_STATUS`]. + register: u64, + /// The energy-status unit, 2^-`unit_shift` J per count: bits 12:8 of the + /// power-unit MSR. Never zero. + unit_shift: u32, +} + +impl Msr { + /// The domain's energy in microjoules, rounded down, at a raw read of its + /// energy-status MSR: the low 32 bits are the count, the rest reserved. + fn uj(&self, raw: u64) -> u64 { + u64::try_from((u128::from(raw & 0xFFFF_FFFF) * 1_000_000) >> self.unit_shift).unwrap_or(u64::MAX) + } + + /// The microjoule figure's wrap at a unit of 2^-`unit_shift` J: 2^32 + /// counts. + fn range_uj(unit_shift: u32) -> u64 { + u64::try_from(((1u128 << 32) * 1_000_000) >> unit_shift).unwrap_or(u64::MAX) + } +} + +impl Domain { + /// The domain's energy now, in microjoules: its `energy_uj`, or its MSR's + /// count at its unit. + fn read_uj(&self) -> Result { + match &self.source { + Source::Powercap(path) => read_u64(path), + Source::Msr(msr) => read_msr(&msr.device, msr.register).map(|raw| msr.uj(raw)).map_err(|e| { + ProbeError::Read(format!("RAPL {}: MSR {:#x} through {}: {e}", self.name, msr.register, msr.device.display())) + }), + } + } +} + pub struct RaplProbe { domains: Vec, /// Why the cumulative count can no longer be trusted, once a package's @@ -81,6 +182,12 @@ pub struct RaplProbe { #[cfg(target_os = "linux")] pub(super) static REAL_RUNS: AtomicUsize = AtomicUsize::new(0); +/// Why the real probe run could not open the host's counters, when it could +/// not: recorded by [`real_run`], so a declined probe says why +/// ([`RaplProbe::given`]) without opening them again. +#[cfg(target_os = "linux")] +static OPEN_FAILURE: OnceLock = OnceLock::new(); + /// This host's RAPL status from one real probe run: the value /// [`rapl_status`](super::rapl_status) caches, and its only source. One /// composition with no branch of its own (count the run, run the real probe, @@ -91,25 +198,50 @@ pub(super) fn host_status() -> CounterStatus { counter_status_of(real_run().verdict()) } -/// The real probe run: [`run_over`] the host's powercap tree with a tick that -/// burns [`PROBE_TICK`] of this thread's CPU time. +/// The real probe run: [`run_over`] the host's counters (its powercap tree, +/// or its MSRs where that lists no package zone) with a tick that burns +/// [`PROBE_TICK`] of this thread's CPU time. Records why they did not open, +/// if they did not. #[cfg(target_os = "linux")] fn real_run() -> RailWatch { - run_over(Path::new(POWERCAP), || counter_tick(PROBE_TICK)) + run_over( + || { + RaplProbe::open().inspect_err(|e| { + let _ = OPEN_FAILURE.set(reason(e).to_string()); + }) + }, + || counter_tick(PROBE_TICK), + ) } -/// [`counter_run`] over the package zones' raw counters under `root`, the -/// given `tick` and a monotonic clock, for [`CPU_RAIL_LIVENESS_BUDGET`]. A -/// tree that does not open (absent, unreadable, no package zone) is never -/// ticked. -fn run_over(root: &Path, tick: impl FnMut() -> crate::probe::liveness::TickOutcome) -> RailWatch { +/// [`counter_run`] over the raw counters of the domains `open` opens, the +/// given `tick` and a monotonic clock, for [`CPU_RAIL_LIVENESS_BUDGET`]. +/// Counters that do not open (absent, unreadable, no package domain) are +/// never ticked. +fn run_over( + open: impl FnOnce() -> Result, + tick: impl FnMut() -> crate::probe::liveness::TickOutcome, +) -> RailWatch { let epoch = Instant::now(); - counter_run( - || RaplProbe::open_in(root).ok().map(|p| move || p.raw_sample()), - tick, - move || epoch.elapsed(), - CPU_RAIL_LIVENESS_BUDGET, - ) + counter_run(|| open().ok().map(|p| move || p.raw_sample()), tick, move || epoch.elapsed(), CPU_RAIL_LIVENESS_BUDGET) +} + +/// A probe error's own text, without the variant's prefix. +fn reason(e: &ProbeError) -> &str { + match e { + ProbeError::Unavailable(why) | ProbeError::Read(why) => why, + } +} + +/// The gate's refusal for `status`, with why the counters did not open +/// appended where that is why the status is `Unavailable`. +fn declined(gate: ProbeError, status: CounterStatus, open_failure: Option<&str>) -> ProbeError { + match (gate, open_failure) { + (ProbeError::Unavailable(gate), Some(why)) if status == CounterStatus::Unavailable => { + ProbeError::Unavailable(format!("{gate}: {why}")) + } + (gate, _) => gate, + } } /// Whether a powercap zone's `name` file names a CPU package: `package-`, @@ -134,77 +266,241 @@ impl RaplProbe { /// The probe the meter uses, built for this host's RAPL `status`, which /// production passes from the cached [`rapl_status`](super::rapl_status) /// and nowhere else: **only when every package counter is live** - /// ([`gated`]). Otherwise `Unavailable`, naming why, and the + /// ([`gated`]). Otherwise `Unavailable`, naming why — with, where the + /// counters did not open, the reason the probe run recorded — and the /// meter estimates instead. #[cfg(target_os = "linux")] pub fn given(status: CounterStatus) -> Result { - gated("RAPL", status, Self::open) + gated("RAPL", status, Self::open).map_err(|e| declined(e, status, OPEN_FAILURE.get().map(String::as_str))) } - /// Open every package zone of the host's powercap tree, with no liveness - /// gate. Used by the liveness probe itself and by [`given`](Self::given) - /// once the gate passed. + /// Open the host's package domains, with no liveness gate: every package + /// zone of its powercap tree, or, where that lists none, every domain's + /// energy-status MSR. Used by the liveness probe itself and by + /// [`given`](Self::given) once the gate passed. #[cfg(target_os = "linux")] pub(super) fn open() -> Result { - Self::open_in(Path::new(POWERCAP)) + Self::open_at(Path::new(POWERCAP), Some(Path::new(HOST_ROOT))) } - /// Open every package zone of the powercap tree at `root`: each top-level - /// `intel-rapl:` zone whose `name` is `package-`, with its readable - /// `energy_uj` and non-zero `max_energy_range_uj`. `Unavailable` when - /// there is no such zone, and when a package zone, or a top-level zone - /// that may be one, cannot be read whole. + /// Open every package zone of the powercap tree at `root`, and only those: + /// no MSR fallback. fn open_in(root: &Path) -> Result { - let entries = fs::read_dir(root).map_err(|e| ProbeError::Unavailable(format!("no powercap: {e}")))?; - let mut domains = Vec::new(); - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if !is_top_level_zone(&name) { - continue; - } - let dir = entry.path(); - // A zone that cannot say what it is may be a package. - let zone = fs::read_to_string(dir.join("name")) - .map_err(|e| ProbeError::Unavailable(format!("RAPL zone {name} has no readable name: {e}")))?; - if !is_package_name(&zone) { - continue; // psys, a top-level dram zone, ...: not the packages' energy - } - let energy_path = dir.join("energy_uj"); - // Unreadable without privileges on many kernels; a package left out - // would leave its energy out of a figure that claims the CPU. - let raw = read_u64(&energy_path).map_err(|e| ProbeError::Unavailable(format!("RAPL package {name}: {e}")))?; - let max_range_uj = read_u64(&dir.join("max_energy_range_uj")) - .ok() - .filter(|&r| r > 0) - .ok_or_else(|| ProbeError::Unavailable(format!("RAPL package {name}: no readable wrap range")))?; - domains.push(Domain { - name, - energy_path, - max_range_uj, - last_raw: raw, - accumulated_uj: 0, - }); - } - if domains.is_empty() { - return Err(ProbeError::Unavailable("no RAPL package zone".into())); - } - // A stable rail order, so consecutive samples pair domain by domain. - domains.sort_by(|a, b| a.name.cmp(&b.name)); + Self::open_at(root, None) + } + + /// Open the package zones of the powercap tree at `powercap`, or, where it + /// lists none and `msr_root` is given, every domain's energy-status MSR + /// under that filesystem root ([`msr_domains`]). `Unavailable`, naming + /// why, when neither opens. + fn open_at(powercap: &Path, msr_root: Option<&Path>) -> Result { + let none_listed = match powercap_domains(powercap)? { + Listed::Packages(domains) => return Ok(RaplProbe { domains, broken: None }), + Listed::None(why) => why, + }; + let Some(root) = msr_root else { + return Err(ProbeError::Unavailable(none_listed)); + }; + let domains = msr_domains(root) + .map_err(|why| ProbeError::Unavailable(format!("{none_listed}; and the energy-status MSRs: {why}")))?; Ok(RaplProbe { domains, broken: None }) } /// One absolute sample for the liveness rule: every package domain's name - /// and raw `energy_uj`, in domain order, as read — never wrap-corrected or - /// summed (a wrap reads as a fall, which is not an advance). `None` if any - /// domain could not be read. + /// and raw energy (`energy_uj`, or an MSR's count in microjoules), in + /// domain order, as read — never wrap-corrected or summed (a wrap reads as + /// a fall, which is not an advance). `None` if any domain could not be + /// read. fn raw_sample(&self) -> Option { - self.domains - .iter() - .map(|d| read_u64(&d.energy_path).ok().map(|raw| (d.name.clone(), raw_i64(raw)))) - .collect() + self.domains.iter().map(|d| d.read_uj().ok().map(|raw| (d.name.clone(), raw_i64(raw)))).collect() } } +/// What a powercap tree lists: its package zones, or why none. +enum Listed { + /// Every package zone, opened. + Packages(Vec), + /// No package zone is listed (no tree, or none named `package-`), and + /// why. + None(String), +} + +/// Every package zone of the powercap tree at `root`: each top-level +/// `intel-rapl:` zone whose `name` is `package-`, with its readable +/// `energy_uj` and non-zero `max_energy_range_uj`. [`Listed::None`] when there +/// is no tree or no such zone; `Unavailable` when a package zone, or a +/// top-level zone that may be one, cannot be read whole. +fn powercap_domains(root: &Path) -> Result { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(e) => return Ok(Listed::None(format!("no powercap at {}: {e}", root.display()))), + }; + let mut domains = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !is_top_level_zone(&name) { + continue; + } + let dir = entry.path(); + // A zone that cannot say what it is may be a package. + let zone = fs::read_to_string(dir.join("name")) + .map_err(|e| ProbeError::Unavailable(format!("RAPL zone {name} has no readable name: {e}")))?; + if !is_package_name(&zone) { + continue; // psys, a top-level dram zone, ...: not the packages' energy + } + let energy_path = dir.join("energy_uj"); + // Unreadable without privileges on many kernels; a package left out + // would leave its energy out of a figure that claims the CPU. + let raw = read_u64(&energy_path).map_err(|e| ProbeError::Unavailable(format!("RAPL package {name}: {e}")))?; + let max_range_uj = read_u64(&dir.join("max_energy_range_uj")) + .ok() + .filter(|&r| r > 0) + .ok_or_else(|| ProbeError::Unavailable(format!("RAPL package {name}: no readable wrap range")))?; + domains.push(Domain { name, source: Source::Powercap(energy_path), max_range_uj, last_raw: raw, accumulated_uj: 0 }); + } + if domains.is_empty() { + return Ok(Listed::None(format!("no RAPL package zone in {}", root.display()))); + } + // A stable rail order, so consecutive samples pair domain by domain. + domains.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Listed::Packages(domains)) +} + +/// The CPU's vendor and family from `/proc/cpuinfo` text: the first CPU's +/// `vendor_id` and `cpu family`. +fn cpu_vendor_family(cpuinfo: &str) -> Option<(&str, u32)> { + let field = |key: &str| { + cpuinfo.lines().find_map(|line| { + let (k, v) = line.split_once(':')?; + (k.trim() == key).then(|| v.trim()) + }) + }; + Some((field("vendor_id")?, field("cpu family")?.parse().ok()?)) +} + +/// The power-unit and energy-status MSRs for a CPU's vendor and family, and +/// whether its RAPL domains are per die (Intel) rather than per package (AMD, +/// Hygon). `None` where this crate knows none: it knows Intel family 6, AMD +/// Zen (family 0x17 and later) and Hygon. +fn msr_registers(vendor: &str, family: u32) -> Option<(u64, u64, bool)> { + match vendor { + "GenuineIntel" if family == 6 => Some((MSR_RAPL_POWER_UNIT, MSR_PKG_ENERGY_STATUS, true)), + "AuthenticAMD" | "HygonGenuine" if family >= 0x17 => { + Some((MSR_AMD_RAPL_POWER_UNIT, MSR_AMD_PKG_ENERGY_STATUS, false)) + } + _ => None, + } +} + +/// `cpu` → `n`; `None` for anything else under the cpu directory. +fn cpu_number(dir_name: &str) -> Option { + let n = dir_name.strip_prefix("cpu")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + n.parse().ok() +} + +fn read_u32(p: &Path) -> Result { + let s = fs::read_to_string(p).map_err(|e| e.to_string())?; + s.trim().parse::().map_err(|e| format!("{:?}: {e}", s.trim())) +} + +/// The 64-bit MSR `register` read through the `msr` driver's device file +/// `device`, where the register number is the file offset. Fails when the file +/// cannot be opened (no `msr` module, no `CAP_SYS_RAWIO`, no read access) or +/// the register cannot be read (the CPU does not implement it). +#[cfg(unix)] +fn read_msr(device: &Path, register: u64) -> std::io::Result { + use std::os::unix::fs::FileExt; + let file = fs::File::open(device)?; + let mut raw = [0u8; 8]; + file.read_exact_at(&mut raw, register)?; + Ok(u64::from_le_bytes(raw)) +} + +#[cfg(not(unix))] +fn read_msr(_device: &Path, _register: u64) -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no msr device file off Unix")) +} + +/// Every RAPL domain's energy-status MSR under the filesystem root `root`: +/// each package (AMD, Hygon) or each die of a package (Intel) of the CPUs +/// under `root/sys/devices/system/cpu`, read through the first of its CPUs +/// whose `root/dev/cpu//msr` reads both its power-unit and its +/// energy-status MSR, at the unit the former reads, in (package, die) order. +/// `Err`, saying why, when the CPU is not one whose MSRs this crate knows, +/// when there is no CPU, when a CPU's package (or die) cannot be read, when a +/// domain's MSRs read through none of its CPUs, or when a domain's energy unit +/// is zero: a figure that left a domain out would miss its energy, and a zero +/// unit is a hypervisor's stand-in, not a counter. +fn msr_domains(root: &Path) -> Result, String> { + let cpuinfo_path = root.join("proc/cpuinfo"); + let cpuinfo = fs::read_to_string(&cpuinfo_path).map_err(|e| format!("{}: {e}", cpuinfo_path.display()))?; + let (vendor, family) = cpu_vendor_family(&cpuinfo) + .ok_or_else(|| format!("{} names no vendor_id and cpu family", cpuinfo_path.display()))?; + let (unit_register, register, per_die) = msr_registers(vendor, family).ok_or_else(|| { + format!( + "a {vendor} family {family:#x} CPU, whose RAPL MSRs this meter does not know (it knows Intel family 6, AMD \ + family 0x17 and later, and Hygon)" + ) + })?; + let cpu_dir = root.join("sys/devices/system/cpu"); + let entries = fs::read_dir(&cpu_dir).map_err(|e| format!("{}: {e}", cpu_dir.display()))?; + let mut by_domain: BTreeMap<(u32, u32), Vec> = BTreeMap::new(); + for entry in entries.flatten() { + let Some(cpu) = cpu_number(&entry.file_name().to_string_lossy()) else { + continue; // cpufreq, cpuidle, online, ... + }; + let topology = entry.path().join("topology"); + // An offline CPU has no topology: its package is unknown, and could be + // one no online CPU covers. + let id = |file: &str| { + read_u32(&topology.join(file)).map_err(|e| { + format!("cpu{cpu} has no readable {file} ({e}): its package is unknown, and could be one no other CPU covers") + }) + }; + let package = id("physical_package_id")?; + let die = if per_die { id("die_id")? } else { 0 }; + by_domain.entry((package, die)).or_default().push(cpu); + } + if by_domain.is_empty() { + return Err(format!("no CPU under {}", cpu_dir.display())); + } + let mut domains = Vec::with_capacity(by_domain.len()); + for ((package, die), mut cpus) in by_domain { + cpus.sort_unstable(); + let name = if per_die { format!("package-{package}-die-{die}") } else { format!("package-{package}") }; + let mut first_failure = None; + let read = cpus.iter().find_map(|cpu| { + let device = root.join(format!("dev/cpu/{cpu}/msr")); + let both = read_msr(&device, unit_register).and_then(|unit| read_msr(&device, register).map(|raw| (unit, raw))); + match both { + Ok((unit, raw)) => Some((device, unit, raw)), + Err(e) => { + first_failure.get_or_insert_with(|| format!("{}: {e}", device.display())); + None + } + } + }); + let Some((device, unit, raw)) = read else { + let first = first_failure.unwrap_or_default(); + return Err(format!( + "{name}: the RAPL MSRs read through none of its CPUs ({} tried; first {first}); {MSR_ACCESS}", + cpus.len() + )); + }; + let unit_shift = ((unit >> 8) & 0x1F) as u32; + if unit_shift == 0 { + return Err(format!("{name}: its power-unit MSR reads a zero energy unit, which is no counter")); + } + let msr = Msr { device, register, unit_shift }; + let last_raw = msr.uj(raw); + domains.push(Domain { name, max_range_uj: Msr::range_uj(unit_shift), last_raw, accumulated_uj: 0, source: Source::Msr(msr) }); + } + Ok(domains) +} + /// The RAPL package counters as one cumulative CPU energy counter, for a /// caller that takes its own before/after differences rather than metering /// spans through [`PowerMeter`](super::PowerMeter) — the S5 executor's @@ -213,16 +509,19 @@ impl RaplProbe { /// `psys`, `dram`, subzones or `intel-rapl-mmio`), every package or none, /// each wrap-corrected at its own `max_energy_range_uj`, a package with no /// readable range never opened, and a fall from above a package's range -/// breaking the count for good. There is no ungated constructor: it opens only -/// once every package counter has proved live. +/// breaking the count for good; on a host whose powercap lists no package +/// zone, every domain's energy-status MSR instead (the module docs). There is +/// no ungated constructor: it opens only once every package counter has +/// proved live. pub struct RaplPackages { probe: RaplProbe, } impl RaplPackages { - /// This host's package zones (`/sys/class/powercap`), only when the - /// cached [`rapl_status`](super::rapl_status) is `Live`: every package - /// counter kept advancing while the probing thread burned CPU. Otherwise + /// This host's package zones (`/sys/class/powercap`), or its + /// energy-status MSRs where powercap lists none, only when the cached + /// [`rapl_status`](super::rapl_status) is `Live`: every package counter + /// kept advancing while the probing thread burned CPU. Otherwise /// `Unavailable`, naming why; always so off Linux. pub fn host() -> Result { #[cfg(target_os = "linux")] @@ -231,7 +530,7 @@ impl RaplPackages { } #[cfg(not(target_os = "linux"))] { - Err(ProbeError::Unavailable("RAPL: no powercap off Linux".into())) + Err(ProbeError::Unavailable("RAPL: no powercap or MSR device off Linux".into())) } } @@ -243,20 +542,57 @@ impl RaplPackages { /// other than the host's sysfs: a test's fake tree. Not cached; the run /// takes about 300 ms over live counters and the whole /// [`CPU_RAIL_LIVENESS_BUDGET`] over a held one. + /// + /// The powercap tree only: a tree that lists no package zone does not + /// open, whatever the host's MSRs say ([`live_at`](Self::live_at) reads + /// those). pub fn live_in( root: &Path, tick: impl FnMut() -> crate::probe::liveness::TickOutcome, ) -> Result { - let status = counter_status_of(run_over(root, tick).verdict()); - gated("RAPL", status, || RaplProbe::open_in(root)).map(|probe| RaplPackages { probe }) + Self::live_with(|| RaplProbe::open_in(root), tick) } - /// The package zones summed, by directory name (`intel-rapl:`), in - /// order. + /// The package domains of a host whose powercap tree is at `powercap` and + /// whose filesystem root is `root`: its package zones, or, where the tree + /// lists none, every domain's energy-status MSR under `root` + /// (`root/proc/cpuinfo`, `root/sys/devices/system/cpu`, + /// `root/dev/cpu//msr`), only when every one proves live, as for + /// [`live_in`](Self::live_in). For a host other than this one: a test's + /// fake tree. Not cached. + pub fn live_at( + powercap: &Path, + root: &Path, + tick: impl FnMut() -> crate::probe::liveness::TickOutcome, + ) -> Result { + Self::live_with(|| RaplProbe::open_at(powercap, Some(root)), tick) + } + + /// The liveness run over what `open` opens, then the gate, naming why the + /// counters did not open where that is why they are not used. + fn live_with( + open: impl Fn() -> Result, + tick: impl FnMut() -> crate::probe::liveness::TickOutcome, + ) -> Result { + let mut failure = None; + let watch = run_over(|| open().inspect_err(|e| failure = Some(reason(e).to_string())), tick); + let status = counter_status_of(watch.verdict()); + gated("RAPL", status, open).map(|probe| RaplPackages { probe }).map_err(|e| declined(e, status, failure.as_deref())) + } + + /// The package domains summed, by name, in order: a zone's directory name + /// (`intel-rapl:`), or, read from the MSRs, `package-

` (AMD, Hygon) + /// or `package-

-die-` (Intel). pub fn zones(&self) -> Vec<&str> { self.probe.domains.iter().map(|d| d.name.as_str()).collect() } + /// Where the counters are read: `rapl-powercap` or `rapl-msr`, the + /// meter's name for the probe. + pub fn source(&self) -> &'static str { + self.probe.name() + } + /// The packages' cumulative energy since this opened, in microjoules: /// each package's deltas, wrap-corrected at its own range, summed. Every /// package is read before any is updated: a package whose counter does @@ -297,8 +633,13 @@ fn read_u64(p: &std::path::Path) -> Result { } impl Probe for RaplProbe { + /// `rapl-powercap` for the powercap zones, `rapl-msr` for the + /// energy-status MSRs. fn name(&self) -> &'static str { - "rapl-powercap" + match self.domains.first().map(|d| &d.source) { + Some(Source::Msr(_)) => "rapl-msr", + Some(Source::Powercap(_)) | None => "rapl-powercap", + } } /// The packages' cumulative energy since the probe opened: each package's @@ -311,7 +652,7 @@ impl Probe for RaplProbe { if let Some(why) = &self.broken { return Err(ProbeError::Read(why.clone())); } - let raws = self.domains.iter().map(|d| read_u64(&d.energy_path)).collect::, _>>()?; + let raws = self.domains.iter().map(Domain::read_uj).collect::, _>>()?; let mut total = 0u64; for (d, raw) in self.domains.iter_mut().zip(raws) { let Some(delta) = zone_delta(d.last_raw, raw, d.max_range_uj) else { @@ -558,7 +899,7 @@ mod tests { /// as Frozen.) fn run_advancing(tree: &FakePowercap, advancing: &[&str]) -> CounterStatus { let mut uj: u64 = 100_000; - let watch = run_over(&tree.root, || { + let watch = run_over(|| RaplProbe::open_in(&tree.root), || { uj += 4_000; for dir in advancing { tree.write(dir, &uj.to_string()); @@ -586,7 +927,7 @@ mod tests { ); let psys_only = FakePowercap::with_zones(&[("intel-rapl:0", Some("psys"), "90000", Some("262143328850"))]); let ticks = std::cell::Cell::new(0); - let watch = run_over(&psys_only.root, || { + let watch = run_over(|| RaplProbe::open_in(&psys_only.root), || { ticks.set(ticks.get() + 1); crate::probe::liveness::TickOutcome { cpu: Duration::ZERO, spins: 1 } }); @@ -722,4 +1063,354 @@ mod tests { Ok(p) => panic!("opened {:?} with package 1 holding", p.zones()), } } + + /// A declined probe names why its counters did not open, where that is + /// why the status is `Unavailable`; a counter that opened and then held + /// is declined for that alone. + #[test] + fn a_declined_probe_says_why_its_counters_did_not_open() { + let gate = || ProbeError::Unavailable("RAPL: the counter could not be opened or read twice".into()); + let why = "package-0-die-0: the RAPL MSRs read through none of its CPUs (1 tried; first /dev/cpu/0/msr: denied)"; + match declined(gate(), CounterStatus::Unavailable, Some(why)) { + ProbeError::Unavailable(s) => assert_eq!(s, format!("RAPL: the counter could not be opened or read twice: {why}")), + e => panic!("{e}"), + } + for (status, failure) in [(CounterStatus::Frozen, Some(why)), (CounterStatus::Unavailable, None)] { + match declined(gate(), status, failure) { + ProbeError::Unavailable(s) => assert_eq!(s, "RAPL: the counter could not be opened or read twice"), + e => panic!("{e}"), + } + } + } + + /// The MSR path, over fake device files: a regular file whose bytes at a + /// register's offset are that register is read exactly as the `msr` + /// driver's device file is. + #[cfg(unix)] + mod msr { + use super::*; + use crate::probe::liveness::TickOutcome; + use std::os::unix::fs::FileExt; + + const INTEL: &str = "processor\t: 0\nvendor_id\t: GenuineIntel\ncpu family\t: 6\nmodel\t\t: 85\n"; + const AMD: &str = "processor\t: 0\nvendor_id\t: AuthenticAMD\ncpu family\t: 26\nmodel\t\t: 112\n"; + /// A power-unit MSR whose energy-status unit (bits 12:8) is 2^-14 J, + /// the common Intel value (0xA0E03). + const UNIT_14: u64 = 0xA_0E03; + /// 2^-16 J, as on AMD Zen. + const UNIT_16: u64 = 0x10 << 8; + + /// A fake filesystem root for one test, removed when dropped: its + /// `proc/cpuinfo`, each CPU's `topology/physical_package_id` and + /// `topology/die_id`, and the CPUs' `dev/cpu//msr` files. + struct MsrRoot(PathBuf); + + impl MsrRoot { + /// `topology[n]` is CPU `n`'s `(package, die)`. + fn new(cpuinfo: &str, topology: &[(u32, u32)]) -> Self { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let root = std::env::temp_dir().join(format!("sandbox-meter-msr-{}-{n}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("proc")).expect("create proc"); + fs::write(root.join("proc/cpuinfo"), cpuinfo).expect("write cpuinfo"); + for (cpu, (package, die)) in topology.iter().enumerate() { + let dir = root.join(format!("sys/devices/system/cpu/cpu{cpu}/topology")); + fs::create_dir_all(&dir).expect("create a CPU's topology"); + fs::write(dir.join("physical_package_id"), format!("{package}\n")).expect("write its package"); + fs::write(dir.join("die_id"), format!("{die}\n")).expect("write its die"); + } + MsrRoot(root) + } + + /// Set CPU `cpu`'s MSR `register` to `value`. + fn set(&self, cpu: u32, register: u64, value: u64) -> &Self { + let dir = self.0.join(format!("dev/cpu/{cpu}")); + fs::create_dir_all(&dir).expect("create dev/cpu/"); + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(dir.join("msr")) + .expect("open the fake msr file"); + file.write_all_at(&value.to_le_bytes(), register).expect("write the register"); + self + } + + fn device(&self, cpu: u32) -> PathBuf { + self.0.join(format!("dev/cpu/{cpu}/msr")) + } + } + + impl Drop for MsrRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// An empty powercap tree: no package zone listed. + fn no_powercap() -> FakePowercap { + FakePowercap::with_zones(&[]) + } + + fn devices(p: &RaplProbe) -> Vec<(&str, PathBuf)> { + p.domains + .iter() + .map(|d| match &d.source { + Source::Msr(m) => (d.name.as_str(), m.device.clone()), + Source::Powercap(path) => panic!("{} is a powercap zone ({})", d.name, path.display()), + }) + .collect() + } + + /// Why the probe over `host` (with no powercap package zone) did not + /// open. + fn refused(host: &MsrRoot) -> String { + let pc = no_powercap(); + match RaplProbe::open_at(&pc.root, Some(&host.0)) { + Err(ProbeError::Unavailable(why)) => { + assert!(why.contains("no RAPL package zone in") && why.contains("; and the energy-status MSRs: "), "{why}"); + why + } + Err(e) => panic!("{e}"), + Ok(p) => panic!("opened {:?}", p.domains.iter().map(|d| &d.name).collect::>()), + } + } + + /// A two-socket Intel host (CPUs 0–1 on package 0, 2–3 on package 1) + /// with no powercap driver: each package's die read through its first + /// CPU, at its unit, and the rise summed through the meter itself as + /// one `HwShunt` CPU record. + #[test] + fn a_two_socket_host_without_powercap_reads_each_packages_msr() { + let host = MsrRoot::new(INTEL, &[(0, 0), (0, 0), (1, 0), (1, 0)]); + host.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 1_000); + host.set(2, MSR_RAPL_POWER_UNIT, UNIT_14).set(2, MSR_PKG_ENERGY_STATUS, 2_000); + let pc = no_powercap(); + let p = RaplProbe::open_at(&pc.root, Some(&host.0)).expect("the MSRs open where powercap lists no package"); + assert_eq!(devices(&p), [("package-0-die-0", host.device(0)), ("package-1-die-0", host.device(2))]); + assert!(p.domains.iter().all(|d| d.max_range_uj == 262_144_000_000), "2^32 counts of 2^-14 J"); + assert_eq!(p.name(), "rapl-msr"); + // 1,000 and 2,000 counts of 61.035 µJ, each floored. + assert_eq!(p.raw_sample().unwrap(), [("package-0-die-0".to_string(), 61_035), ("package-1-die-0".to_string(), 122_070)]); + + let mut meter = PowerMeter { probes: vec![Box::new(p)], tdp_watts: 10.0 }; + let ((), m) = meter.measure(|| { + host.set(0, MSR_PKG_ENERGY_STATUS, 3_000).set(2, MSR_PKG_ENERGY_STATUS, 2_100); + }); + let cpu: Vec<_> = m.devices.iter().map(|d| (d.class, d.measured_uj, d.provenance)).collect(); + // 183,105 − 61,035 plus 128,173 − 122,070. + assert_eq!(cpu, [(DeviceClass::Cpu, Some(128_173), Provenance::HwShunt)], "{:?}", m.devices); + assert_eq!(m.source, "rapl-msr"); + } + + /// The 32-bit count wraps at 2^32 counts, so the microjoule figure wraps + /// at that range, losing at most a microjoule; bits above the count are + /// not energy. + #[test] + fn the_count_wraps_at_2_to_the_32_counts() { + let host = MsrRoot::new(INTEL, &[(0, 0)]); + host.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 0xFFFF_FFF0); + let pc = no_powercap(); + let mut p = RaplProbe::open_at(&pc.root, Some(&host.0)).unwrap(); + host.set(0, MSR_PKG_ENERGY_STATUS, 0xABCD_0000_0000_0010); + // 32 counts of 61.035 µJ is 1,953.125 µJ. + assert_eq!(p.snapshot().unwrap(), [(DeviceClass::Cpu, 1_953)]); + } + + /// AMD Zen reads its own power-unit and energy-status MSRs, one domain + /// per package whatever its CPUs' `die_id`. + #[test] + fn amd_zen_reads_its_own_msrs_one_domain_per_package() { + let host = MsrRoot::new(AMD, &[(0, 0), (0, 1)]); + host.set(0, MSR_AMD_RAPL_POWER_UNIT, UNIT_16).set(0, MSR_AMD_PKG_ENERGY_STATUS, 65_536); + let pc = no_powercap(); + let mut p = RaplProbe::open_at(&pc.root, Some(&host.0)).expect("AMD's MSRs open"); + assert_eq!(devices(&p), [("package-0", host.device(0))]); + assert_eq!(p.raw_sample().unwrap(), [("package-0".to_string(), 1_000_000)], "65,536 counts of 2^-16 J"); + host.set(0, MSR_AMD_PKG_ENERGY_STATUS, 131_072); + assert_eq!(p.snapshot().unwrap(), [(DeviceClass::Cpu, 1_000_000)]); + } + + /// An Intel package of two dies is two domains, each read through the + /// first CPU of its die, and summed. + #[test] + fn an_intel_package_of_two_dies_is_two_domains() { + let host = MsrRoot::new(INTEL, &[(0, 0), (0, 0), (0, 1), (0, 1)]); + for cpu in [0, 2] { + host.set(cpu, MSR_RAPL_POWER_UNIT, UNIT_14).set(cpu, MSR_PKG_ENERGY_STATUS, 0); + } + let pc = no_powercap(); + let mut p = RaplProbe::open_at(&pc.root, Some(&host.0)).unwrap(); + assert_eq!(devices(&p), [("package-0-die-0", host.device(0)), ("package-0-die-1", host.device(2))]); + host.set(0, MSR_PKG_ENERGY_STATUS, 16_384).set(2, MSR_PKG_ENERGY_STATUS, 32_768); + assert_eq!(p.snapshot().unwrap(), [(DeviceClass::Cpu, 3_000_000)], "one joule and two"); + } + + /// A domain is read through the first of its CPUs whose MSRs read. + #[test] + fn a_domain_reads_through_the_first_of_its_cpus_whose_msrs_read() { + let host = MsrRoot::new(INTEL, &[(0, 0), (0, 0)]); + host.set(1, MSR_RAPL_POWER_UNIT, UNIT_14).set(1, MSR_PKG_ENERGY_STATUS, 5); + let pc = no_powercap(); + let p = RaplProbe::open_at(&pc.root, Some(&host.0)).unwrap(); + assert_eq!(devices(&p), [("package-0-die-0", host.device(1))], "cpu0 has no device file"); + } + + /// No MSR counter, and why, where a power-unit MSR reads a zero unit (a + /// hypervisor's stand-in, seen under KVM), where the CPU is not one + /// whose MSRs are known, where a domain's device files do not read, + /// where a CPU's topology does not read (an offline CPU), or where an + /// Intel CPU names no die. + #[test] + fn no_msr_counter_unless_every_domain_reads_at_a_known_unit() { + let zero = MsrRoot::new(INTEL, &[(0, 0)]); + zero.set(0, MSR_RAPL_POWER_UNIT, 0).set(0, MSR_PKG_ENERGY_STATUS, 0); + assert!(refused(&zero).contains("package-0-die-0: its power-unit MSR reads a zero energy unit")); + + let arm = MsrRoot::new("processor\t: 0\nCPU implementer\t: 0x41\n", &[(0, 0)]); + assert!(refused(&arm).contains("names no vendor_id and cpu family")); + + let netburst = MsrRoot::new("vendor_id\t: GenuineIntel\ncpu family\t: 15\n", &[(0, 0)]); + netburst.set(0, MSR_RAPL_POWER_UNIT, UNIT_14); + assert!(refused(&netburst).contains("family 0xf CPU, whose RAPL MSRs this meter does not know")); + + let missing = MsrRoot::new(INTEL, &[(0, 0), (1, 0)]); + missing.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 1); + let why = refused(&missing); + assert!(why.contains("package-1-die-0: the RAPL MSRs read through none of its CPUs (1 tried"), "{why}"); + assert!(why.contains(&host_path(&missing.device(1))) && why.contains("CAP_SYS_RAWIO"), "{why}"); + + let offline = MsrRoot::new(INTEL, &[(0, 0)]); + offline.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 1); + fs::create_dir_all(offline.0.join("sys/devices/system/cpu/cpu1")).unwrap(); + assert!(refused(&offline).contains("cpu1 has no readable physical_package_id")); + + let no_die = MsrRoot::new(INTEL, &[(0, 0)]); + no_die.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 1); + fs::remove_file(no_die.0.join("sys/devices/system/cpu/cpu0/topology/die_id")).unwrap(); + assert!(refused(&no_die).contains("cpu0 has no readable die_id")); + + let no_cpu = MsrRoot::new(INTEL, &[]); + assert!(refused(&no_cpu).contains("sys/devices/system/cpu")); + } + + fn host_path(p: &Path) -> String { + p.display().to_string() + } + + /// Powercap zones are preferred, read through powercap or not at all; + /// the MSRs are read only where powercap lists no package zone, and + /// never by the powercap-only constructor. + #[test] + fn powercap_is_preferred_and_the_msrs_are_the_fallback() { + let host = MsrRoot::new(INTEL, &[(0, 0)]); + host.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 5); + + let zones = two_sockets_with_psys_and_dram(); + let p = RaplProbe::open_at(&zones.root, Some(&host.0)).unwrap(); + assert_eq!((p.name(), p.domains.len()), ("rapl-powercap", 2)); + assert!(p.domains.iter().all(|d| matches!(d.source, Source::Powercap(_))), "powercap listed its packages"); + + let psys_only = FakePowercap::with_zones(&[("intel-rapl:0", Some("psys"), "90000", Some("262143328850"))]); + let p = RaplProbe::open_at(&psys_only.root, Some(&host.0)).unwrap(); + assert_eq!(devices(&p), [("package-0-die-0", host.device(0))], "psys is no package: the MSRs are read"); + + let unreadable = FakePowercap::with_zones(&[("intel-rapl:0", Some("package-0"), "not a number", Some("262143328850"))]); + match RaplProbe::open_at(&unreadable.root, Some(&host.0)) { + Err(ProbeError::Unavailable(why)) => { + assert!(why.contains("intel-rapl:0") && !why.contains("energy-status MSRs"), "{why}") + } + Err(e) => panic!("{e}"), + Ok(p) => panic!("read {} where powercap listed an unreadable package", p.name()), + } + + let pc = no_powercap(); + match RaplProbe::open_in(&pc.root) { + Err(ProbeError::Unavailable(why)) => assert!(!why.contains("MSR"), "{why}"), + Err(e) => panic!("{e}"), + Ok(p) => panic!("the powercap-only constructor opened {}", p.name()), + } + let gone = RaplPackages::live_in(&pc.root, || TickOutcome { cpu: Duration::ZERO, spins: 1 }); + assert!(gone.is_err(), "live_in read the MSRs"); + } + + /// The liveness run over MSR domains, through the public counter: open + /// while every domain's count advances, with its figure the domains' + /// rise; declined, naming RAPL, while one holds; and declined, saying + /// why, where a device does not read. + #[test] + fn the_public_counter_over_msr_domains_opens_only_when_every_one_is_live() { + fn live_at_advancing(host: &MsrRoot, advancing: &[u32]) -> Result { + let pc = no_powercap(); + let mut count = 1_000_000u64; + RaplPackages::live_at(&pc.root, &host.0, || { + count += 18_432; // 45 W over a 25 ms tick, in 61.035 µJ counts + for &cpu in advancing { + host.set(cpu, MSR_PKG_ENERGY_STATUS, count); + } + std::thread::sleep(Duration::from_millis(25)); + TickOutcome { cpu: Duration::from_millis(25), spins: 1 } + }) + } + let two = || { + let host = MsrRoot::new(INTEL, &[(0, 0), (1, 0)]); + for cpu in [0, 1] { + host.set(cpu, MSR_RAPL_POWER_UNIT, UNIT_14).set(cpu, MSR_PKG_ENERGY_STATUS, 1_000_000); + } + host + }; + let host = two(); + let mut packages = live_at_advancing(&host, &[0, 1]).expect("both packages live"); + assert_eq!((packages.zones(), packages.source()), (vec!["package-0-die-0", "package-1-die-0"], "rapl-msr")); + let before = packages.cumulative_uj().unwrap(); + host.set(0, MSR_PKG_ENERGY_STATUS, 5_000_000).set(1, MSR_PKG_ENERGY_STATUS, 5_016_384); + let after = packages.cumulative_uj().unwrap(); + assert!(after > before, "{before} -> {after}"); + + let host = two(); + match live_at_advancing(&host, &[0]) { + Err(ProbeError::Unavailable(why)) => assert!(why.contains("RAPL"), "{why}"), + Err(e) => panic!("{e}"), + Ok(p) => panic!("opened {:?} with package 1 holding", p.zones()), + } + + let unreadable = MsrRoot::new(INTEL, &[(0, 0)]); + fs::create_dir_all(unreadable.0.join("dev/cpu/0/msr")).unwrap(); // a device that does not read + match live_at_advancing(&unreadable, &[]) { + Err(ProbeError::Unavailable(why)) => assert!( + why.starts_with("RAPL: ") && why.contains("none of its CPUs") && why.contains("CAP_SYS_RAWIO"), + "{why}" + ), + Err(e) => panic!("{e}"), + Ok(p) => panic!("opened {:?}", p.zones()), + } + } + + /// The production liveness run over MSR domains judges them by the same + /// rule: live when every domain's count keeps advancing, frozen when + /// one holds. + #[test] + fn the_liveness_run_judges_msr_domains_by_the_same_rule() { + let run = |advancing: &[u32]| { + let host = MsrRoot::new(INTEL, &[(0, 0), (1, 0)]); + for cpu in [0, 1] { + host.set(cpu, MSR_RAPL_POWER_UNIT, UNIT_14).set(cpu, MSR_PKG_ENERGY_STATUS, 1_000_000); + } + let pc = no_powercap(); + let mut count = 1_000_000u64; + let watch = run_over(|| RaplProbe::open_at(&pc.root, Some(&host.0)), || { + count += 18_432; + for &cpu in advancing { + host.set(cpu, MSR_PKG_ENERGY_STATUS, count); + } + std::thread::sleep(Duration::from_millis(25)); + TickOutcome { cpu: Duration::from_millis(25), spins: 1 } + }); + counter_status_of(watch.verdict()) + }; + assert_eq!(run(&[0, 1]), CounterStatus::Live); + assert_eq!(run(&[0]), CounterStatus::Frozen, "package 1 held its count"); + } + } } diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/counter_probes_once_per_process.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/counter_probes_once_per_process.rs index f85e8e4ca..ad14a0585 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/counter_probes_once_per_process.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/counter_probes_once_per_process.rs @@ -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 { diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs index 9adfc5f01..5fb4594ff 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs @@ -12,7 +12,10 @@ //! 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")] @@ -374,6 +377,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 +402,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::>()); 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 +431,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 +488,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( diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs new file mode 100644 index 000000000..36b6b6d20 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs @@ -0,0 +1,352 @@ +//! 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//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 ``. + 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:` zone that is a + /// package (`name` reads `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 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) => name.trim().strip_prefix("package-").is_some_and(digits), + Err(_) => true, + } + }) + } + + /// One RAPL energy domain as the oracle reads it. + #[derive(Debug)] + pub struct Domain { + /// `package-

` (AMD, Hygon) or `package-

-die-` (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 { + 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 { + 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, 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::().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> = 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::().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> { + 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) { + 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"); +} -- 2.54.0 (Apple Git-157) From 3e5a9cec266fe01644b2f67027c72a8b690a6f21 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 4 Nov 2026 21:18:49 -0500 Subject: [PATCH 2/2] sandbox-meter: a powercap zone named package-

-die- is a package's domain On a multi-die Intel package the kernel scopes RAPL per die and lists one top-level intel-rapl: zone per die, named "package-

-die-" (intel_rapl_common's "package-%d-die-%d", used when the MSRs are not package scope and a package has more than one die). The probe accepted only "package-", so such a host listed no package zone: estimated, or, with the MSR fallback, read through the MSRs as root only. A zone's name now names a package's domain when it is "package-" or "package--die-", and nothing looser (not psys, dram, "package-", "package-0-die-", "package-0-core-0", "package-0-die-1-die-2"), so every die's zone is summed into the CPU figure under the same rules: every domain readable and live, or no figure. The hardware tests' oracles (the powercap one, and the MSR one's check for a powercap package) apply the same name rule. Tests: the name rule over both forms and the near misses; a fake two-die powercap tree (two die zones, psys, each die's core subzone) holding exactly the two die zones, metering a span as their rise alone, and frozen when one die holds. Pass on Linux x86_64 (cargo test -p sandbox-meter; clippy clean apart from the existing type_complexity errors in tests/nvml_hardware.rs) and in the macOS CI step (both greps; clippy on sandbox-meter and sandbox-proof clean). Co-Authored-By: Claude Opus 5.5 --- .../crates/sandbox-meter/src/probe/mod.rs | 3 +- .../crates/sandbox-meter/src/probe/rapl.rs | 122 +++++++++++++++--- .../sandbox-meter/tests/rapl_hardware.rs | 15 ++- .../sandbox-meter/tests/rapl_msr_hardware.rs | 14 +- 4 files changed, 129 insertions(+), 25 deletions(-) diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs index a28597acd..39b21a0b3 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/mod.rs @@ -14,7 +14,8 @@ //! 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-` (never +//! `HwShunt` — summing the zones whose `name` is `package-` (or +//! `package-

-die-`, 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//msr` (`rapl-msr`; it needs the diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs index d41dc819b..ea28ff715 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs @@ -1,15 +1,18 @@ //! Intel/AMD RAPL — package-domain cumulative energy, through the Linux //! powercap interface (`/sys/class/powercap/intel-rapl:N/energy_uj`, for every -//! top-level zone whose `name` is `package-`), or, on a kernel that lists no -//! package zone, from each domain's energy-status MSR. Each domain is -//! wrap-corrected against its own range. `HwShunt` provenance. +//! top-level zone whose `name` is `package-`, or `package-

-die-` on a +//! multi-die Intel package), or, on a kernel that lists no package zone, from +//! each domain's energy-status MSR. Each domain is wrap-corrected against its +//! own range. `HwShunt` provenance. //! //! **The package zones and no other.** A top-level powercap zone is not //! necessarily a package. Many Intel hosts expose `psys`, the platform zone, //! whose count already includes the packages', so summing it beside them //! counts their energy twice; some expose a top-level `dram` zone, which is not //! CPU energy at all. The `Cpu` figure is the sum of every zone whose `name` -//! file reads `package-` — every socket's — and nothing else: not +//! file reads `package-`, or `package--die-` — the +//! kernel lists one such zone per die of a multi-die Intel package, whose RAPL +//! is per die — every socket's and every die's, and nothing else: not //! `psys`, not `dram`, not a package's subzones (`intel-rapl:0:0`, its `core`, //! `uncore` or `dram`), and not the second interface to the same packages //! (`intel-rapl-mmio:N`). Every package or none: a package zone whose counter @@ -244,13 +247,22 @@ fn declined(gate: ProbeError, status: CounterStatus, open_failure: Option<&str>) } } -/// Whether a powercap zone's `name` file names a CPU package: `package-`, -/// `` one or more ASCII digits, surrounding whitespace allowed. Not `psys`, -/// `dram`, `core`, `uncore`, or anything else. +/// Whether a powercap zone's `name` file names a CPU package's domain: +/// `package-

`, or `package-

-die-` — the kernel's name for each die's +/// zone of a multi-die Intel package, whose RAPL is per die +/// (`intel_rapl_common`'s `"package-%d-die-%d"`) — each number one or more +/// ASCII digits, surrounding whitespace allowed. Not `psys`, `dram`, `core`, +/// `uncore`, a truncated name (`package-`, `package-0-die-`), another suffix +/// (`package-0-core-0`), or anything else. fn is_package_name(name: &str) -> bool { - name.trim() - .strip_prefix("package-") - .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + let Some(rest) = name.trim().strip_prefix("package-") else { + return false; + }; + match rest.split_once("-die-") { + Some((package, die)) => digits(package) && digits(die), + None => digits(rest), + } } /// Whether a powercap directory is a top-level RAPL zone, `intel-rapl:`: @@ -320,13 +332,14 @@ impl RaplProbe { enum Listed { /// Every package zone, opened. Packages(Vec), - /// No package zone is listed (no tree, or none named `package-`), and - /// why. + /// No package zone is listed (no tree, or none named `package-` or + /// `package-

-die-`), and why. None(String), } /// Every package zone of the powercap tree at `root`: each top-level -/// `intel-rapl:` zone whose `name` is `package-`, with its readable +/// `intel-rapl:` zone whose `name` is `package-` or +/// `package-

-die-` ([`is_package_name`]), with its readable /// `energy_uj` and non-zero `max_energy_range_uj`. [`Listed::None`] when there /// is no tree or no such zone; `Unavailable` when a package zone, or a /// top-level zone that may be one, cannot be read whole. @@ -505,7 +518,8 @@ fn msr_domains(root: &Path) -> Result, String> { /// caller that takes its own before/after differences rather than metering /// spans through [`PowerMeter`](super::PowerMeter) — the S5 executor's /// `RaplMeter`. It is the probe the meter itself uses, under the same rules: -/// every top-level zone whose `name` is `package-` and nothing else (not +/// every top-level zone whose `name` is `package-` (or, per die of a +/// multi-die Intel package, `package-

-die-`) and nothing else (not /// `psys`, `dram`, subzones or `intel-rapl-mmio`), every package or none, /// each wrap-corrected at its own `max_energy_range_uj`, a package with no /// readable range never opened, and a fall from above a package's range @@ -819,14 +833,44 @@ mod tests { ]) } - /// The package zones by their `name` file, and no other: `package-`; - /// the top-level zones by their directory: `intel-rapl:`. + /// The package zones by their `name` file, and no other: `package-`, + /// or `package-

-die-` for a die of a multi-die package; the + /// top-level zones by their directory: `intel-rapl:`. #[test] fn a_package_is_a_top_level_zone_named_package_n() { - for name in ["package-0", "package-1\n", "package-12", " package-3 \n"] { + for name in [ + "package-0", + "package-1\n", + "package-12", + " package-3 \n", + "package-0-die-0", + "package-0-die-1\n", + "package-1-die-0", + "package-12-die-34", + ] { assert!(is_package_name(name), "{name:?}"); } - for name in ["psys", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", "package0", ""] { + for name in [ + "psys", + "dram", + "core", + "uncore", + "package-", + "package-x", + "package-0a", + "Package-0", + "package0", + "", + "package-0-die-", + "package--die-0", + "package-0-die-x", + "package-0-die-1a", + "package-0-die-1-die-2", + "package-0-core-0", + "package-0-die", + "package-0-Die-1", + "package-0-die-0-dram", + ] { assert!(!is_package_name(name), "{name:?}"); } for dir in ["intel-rapl:0", "intel-rapl:12"] { @@ -884,6 +928,48 @@ mod tests { assert_eq!(cpu, [(DeviceClass::Cpu, Some(12_487), Provenance::HwShunt)], "{:?}", m.devices); } + /// A multi-die Intel package lists one top-level zone per die, named + /// `package-

-die-`: the probe holds both of its dies' zones (and + /// neither `psys` nor a die's `core` subzone), the liveness run watches + /// both, and a span over which every zone rose is metered as the two dies' + /// rise alone. Powercap listed them, so the MSRs are never looked at. + #[test] + fn a_two_die_package_sums_each_dies_zone() { + let tree = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("package-0-die-0"), "1000", Some("262143328850")), + ("intel-rapl:1", Some("package-0-die-1"), "2000", Some("262143328850")), + ("intel-rapl:2", Some("psys"), "90000", Some("262143328850")), + ("intel-rapl:0:0", Some("core"), "700", Some("262143328850")), + ("intel-rapl:1:0", Some("core"), "800", Some("262143328850")), + ]); + let absent_root = tree.root.join("no-such-root"); + let p = RaplProbe::open_at(&tree.root, Some(&absent_root)).expect("a tree with two die zones opens"); + let held: Vec<&str> = p.domains.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(held, ["intel-rapl:0", "intel-rapl:1"], "not exactly the two dies' zones"); + assert_eq!(p.name(), "rapl-powercap"); + assert_eq!(p.raw_sample().unwrap(), [("intel-rapl:0".to_string(), 1_000), ("intel-rapl:1".to_string(), 2_000)]); + + let mut meter = PowerMeter { probes: vec![Box::new(p)], tdp_watts: 10.0 }; + let ((), m) = meter.measure(|| { + tree.write("intel-rapl:0", "4000"); + tree.write("intel-rapl:1", "7000"); + tree.write("intel-rapl:2", "190000"); + tree.write("intel-rapl:0:0", "2700"); + tree.write("intel-rapl:1:0", "1800"); + }); + let cpu: Vec<_> = m.devices.iter().map(|d| (d.class, d.measured_uj, d.provenance)).collect(); + assert_eq!(cpu, [(DeviceClass::Cpu, Some(8_000), Provenance::HwShunt)], "not the two dies' rise alone"); + + let mut uj: u64 = 100_000; + let watch = run_over(|| RaplProbe::open_in(&tree.root), || { + uj += 4_000; + tree.write("intel-rapl:0", &uj.to_string()); // die 1 holds + std::thread::sleep(Duration::from_millis(25)); + crate::probe::liveness::TickOutcome { cpu: Duration::from_millis(25), spins: 1 } + }); + assert_eq!(counter_status_of(watch.verdict()), CounterStatus::Frozen, "die 1 held beside a live die 0"); + } + /// The liveness run — the one `rapl_status` runs on Linux, here over a /// fake tree with a sleeping tick — watches the package zones only: Live /// while both packages advance with `psys` and `dram` holding; Frozen diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs index 5fb4594ff..acefef049 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_hardware.rs @@ -5,7 +5,8 @@ //! 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 +//! applies the sustained-advance rule to the top-level `package-` (and +//! `package-

-die-`) 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 @@ -122,9 +123,17 @@ mod oracle { z.dir.strip_prefix("intel-rapl:").is_some_and(digits) } - /// A package zone: top-level, named `package-`. + /// A package zone: top-level, named `package-`, or `package-

-die-` + /// (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. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs index 36b6b6d20..d31d4b173 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/rapl_msr_hardware.rs @@ -82,15 +82,23 @@ mod oracle { } /// Whether powercap lists a top-level `intel-rapl:` zone that is a - /// package (`name` reads `package-`) or may be one (`name` does not - /// read): the meter then reads powercap or nothing, never the MSRs. + /// package (`name` reads `package-`, or `package-

-die-` 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) => name.trim().strip_prefix("package-").is_some_and(digits), + Ok(name) => named(&name), Err(_) => true, } }) -- 2.54.0 (Apple Git-157)