jouleclaw-energy: RAPL reads each package's energy-status MSR where powercap lists none #618

Merged
dcharlot merged 2 commits from jouleclaw-energy/rapl-msr into main 2026-11-05 04:28:34 -05:00
3 changed files with 648 additions and 36 deletions

View file

@ -26,7 +26,7 @@ serde = ["dep:serde", "dep:serde_json"]
# Per-platform energy backends. Off by default so the foundation is OS-neutral;
# downstream crates / binaries enable what their target supports.
rapl = [] # Intel/AMD x86 — Linux sysfs powercap (HwShunt while its counter is live)
rapl = [] # Intel/AMD x86 — Linux sysfs powercap, or the energy-status MSRs where powercap lists no package (HwShunt while live)
nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumulative-energy counter (HwShunt while live), power-draw integral (ModelBased) otherwise
ioreport = [] # Apple Silicon M-series — real IOReport energy counter (HwShunt while every CPU rail is live), load-avg estimator fallback; and the per-process GPU counter (GPU rail × this process's GPU-time share)
ina3221 = [] # NVIDIA Jetson (Orin / Thor) — sysfs i2c shunt reader

View file

@ -18,6 +18,29 @@
//! itself updates at ~1 kHz; below this readings are quantization
//! noise).
//!
//! **Without the powercap driver.** Some kernels ship without `intel_rapl_msr`
//! (Ubuntu without `linux-modules-extra`, as on bare-metal hosts rented by the
//! node), so no package zone is listed while the counters themselves are
//! there. Where powercap lists no package, the counter reads the same counters
//! directly: each RAPL domain's energy-status MSR (`MSR_PKG_ENERGY_STATUS`,
//! 0x611, on Intel family 6; `0xC001029B` on AMD Zen and Hygon), through the
//! `msr` driver's device file of the domain's first CPU (`/dev/cpu/<n>/msr`,
//! which needs the `msr` module, `CAP_SYS_RAWIO` and read access to the file).
//! A domain is what the kernel scopes these MSRs to: on AMD and Hygon the
//! package, whose energy counter is per package; on Intel a die of a package
//! (`topology/die_id` within `topology/physical_package_id`), since its RAPL
//! MSRs are die-scope on multi-die parts — on a single-die part, the package.
//! The 32-bit count is converted at the domain's own energy-status unit (bits
//! 12:8 of the power-unit MSR, 0x606 or `0xC0010299`), so it wraps at 2^32
//! counts in microjoules, and its resolution is one count (about 61 μJ at the
//! common unit of 2^-14 J). A power-unit MSR that reads zero has no counter:
//! some hypervisors let a guest read these MSRs and return zeros. The same
//! liveness rule then decides whether the counter reads.
//!
//! **Multi-die packages.** The kernel names a multi-die Intel package's powercap
//! zones `package-<p>-die-<d>`, one top-level zone per die; each is a package
//! zone here, and the figure sums them all.
//!
//! **Liveness.** A vendor energy counter is a hardware reading only while it is
//! demonstrably live for the domain the figure claims (JouleClaw SPEC §1.3).
//! A readable `energy_uj` is not yet a live one: a hypervisor can pass the file
@ -56,24 +79,78 @@ pub const POWERCAP_ROOT: &str = "/sys/class/powercap";
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const PROBE_TICK: Duration = Duration::from_millis(25);
/// One RAPL package zone: a top-level `intel-rapl:<index>` zone whose `name`
/// reads `package-<n>`.
/// One RAPL package: a top-level `intel-rapl:<index>` powercap zone whose
/// `name` reads `package-<n>`, or, on a kernel without the powercap driver,
/// the package's energy-status MSR ([`Msr`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Package {
/// The zone's directory, `<powercap>/intel-rapl:<index>`.
/// The zone's directory, `<powercap>/intel-rapl:<index>`; for an MSR
/// package, the device file it is read through, `<root>/dev/cpu/<n>/msr`.
dir: PathBuf,
/// `<index>` in `intel-rapl:<index>`.
/// `<index>` in `intel-rapl:<index>`; for an MSR package, its
/// `physical_package_id`.
index: u32,
/// The zone's `max_energy_range_uj`, the value its `energy_uj` wraps at,
/// when that reads as a number above zero.
/// For an MSR package on Intel, its die within the package (`die_id`): the
/// kernel scopes Intel's RAPL MSRs per die. 0 for a powercap zone, an AMD
/// or Hygon package, and a single-die part.
die: u32,
/// The value the package's microjoule figure wraps at: the zone's
/// `max_energy_range_uj` when that reads as a number above zero; for an MSR
/// package, 2^32 counts at its unit ([`Msr::range_uj`]).
max_energy_range_uj: Option<u64>,
/// How to read an MSR package; `None` for a powercap zone.
msr: Option<Msr>,
}
/// Whether a top-level zone's `name` file names a package: `package-<n>`, `<n>`
/// one or more digits (the file ends in a newline). Not `psys`, `dram`, `core`
/// or `uncore`.
/// A package read from its energy-status MSR, where the kernel lists no
/// powercap zone for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Msr {
/// 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,
}
/// Intel's power-unit MSR (family 6).
pub(crate) const MSR_RAPL_POWER_UNIT: u64 = 0x606;
/// Intel's package energy-status MSR (family 6).
pub(crate) const MSR_PKG_ENERGY_STATUS: u64 = 0x611;
/// AMD Zen's (and Hygon's) power-unit MSR.
pub(crate) const MSR_AMD_RAPL_POWER_UNIT: u64 = 0xC001_0299;
/// AMD Zen's (and Hygon's) package energy-status MSR.
pub(crate) const MSR_AMD_PKG_ENERGY_STATUS: u64 = 0xC001_029B;
impl Msr {
/// The package's energy, in microjoules, at a raw read of its
/// energy-status MSR: the low 32 bits are the count.
fn uj(self, raw: u64) -> u64 {
((u128::from(raw & 0xFFFF_FFFF) * 1_000_000) >> self.unit_shift) as u64
}
/// The microjoule figure's wrap: 2^32 counts.
fn range_uj(self) -> u64 {
(((1u128 << 32) * 1_000_000) >> self.unit_shift) as u64
}
/// One count, in whole microjoules rounded up: the counter's resolution.
fn count_uj(self) -> u64 {
1_000_000u64.div_ceil(1u64 << self.unit_shift).max(1)
}
}
/// Whether a top-level zone's `name` file names a package: `package-<n>`, or
/// `package-<n>-die-<m>` — the kernel's name for each die's zone of a multi-die
/// Intel package — `<n>` and `<m>` one or more digits (the file ends in a
/// newline). Not `psys`, `dram`, `core` or `uncore`.
pub(crate) 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 = |n: &str| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit());
name.trim().strip_prefix("package-").is_some_and(|n| match n.split_once("-die-") {
Some((package, die)) => digits(package) && digits(die),
None => digits(n),
})
}
/// The index of a top-level RAPL zone directory, `intel-rapl:<index>`. `None`
@ -94,7 +171,8 @@ fn read_u64(path: &Path) -> Option<u64> {
}
/// The package zones under the powercap directory `root`, in index order: every
/// top-level zone whose `name` reads `package-<n>`, with the range its counter
/// top-level zone whose `name` reads `package-<n>` (or `package-<n>-die-<m>`,
/// [`is_package_name`]), with the range its counter
/// wraps at. Everything else is left out, zone 0 included if it is not a
/// package. Empty when no zone is a package, or `root` cannot be listed.
pub(crate) fn packages_under(root: &Path) -> Vec<Package> {
@ -111,6 +189,8 @@ pub(crate) fn packages_under(root: &Path) -> Vec<Package> {
max_energy_range_uj: read_u64(&dir.join("max_energy_range_uj")).filter(|&m| m > 0),
dir,
index,
die: 0,
msr: None,
})
})
.collect();
@ -118,14 +198,146 @@ pub(crate) fn packages_under(root: &Path) -> Vec<Package> {
packages
}
/// Every package's `energy_uj`, in order. `None` when there is no package, or
/// when any one cannot be read: a figure that left a package out would miss its
/// energy while claiming the whole CPU.
/// Every package's energy in microjoules (its `energy_uj`, or its MSR's count
/// at its unit), in order. `None` when there is no package, or when any one
/// cannot be read: a figure that left a package out would miss its energy
/// while claiming the whole CPU.
pub(crate) fn read_packages(packages: &[Package]) -> Option<Vec<u64>> {
if packages.is_empty() {
return None;
}
packages.iter().map(|p| read_u64(&p.dir.join("energy_uj"))).collect()
packages.iter().map(read_package).collect()
}
/// One package's energy in microjoules, or `None` when it cannot be read.
fn read_package(p: &Package) -> Option<u64> {
match p.msr {
None => read_u64(&p.dir.join("energy_uj")),
Some(msr) => read_msr(&p.dir, msr.register).map(|raw| msr.uj(raw)),
}
}
/// The 64-bit MSR `register` read through the `msr` driver's device file
/// `device`, where the register number is the file offset. `None` 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) -> Option<u64> {
use std::os::unix::fs::FileExt;
let file = std::fs::File::open(device).ok()?;
let mut raw = [0u8; 8];
file.read_exact_at(&mut raw, register).ok()?;
Some(u64::from_le_bytes(raw))
}
#[cfg(not(unix))]
fn read_msr(_device: &Path, _register: u64) -> Option<u64> {
None
}
/// 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, or
/// `None` where this crate knows none: Intel family 6, AMD Zen (family 0x17
/// and later) and Hygon.
fn msr_registers(vendor: &str, family: u32) -> Option<(u64, u64)> {
match vendor {
"GenuineIntel" if family == 6 => Some((MSR_RAPL_POWER_UNIT, MSR_PKG_ENERGY_STATUS)),
"AuthenticAMD" | "HygonGenuine" if family >= 0x17 => {
Some((MSR_AMD_RAPL_POWER_UNIT, MSR_AMD_PKG_ENERGY_STATUS))
}
_ => None,
}
}
/// Each RAPL domain's lowest-numbered CPU, keyed by (package, die), from
/// `<root>/sys/devices/system/cpu/cpu<n>/topology/`: its `physical_package_id`
/// and, where `per_die` (Intel), its `die_id` (0 where the file is absent). Linux
/// scopes Intel's RAPL MSRs per die and AMD's and Hygon's per package (its perf
/// RAPL driver: "AMD/HYGON platforms have a per-PKG package energy counter";
/// "CLX-AP is multi-die and its RAPL MSRs are die-scope"), so on AMD and Hygon
/// the die is always 0.
fn first_cpu_per_domain(root: &Path, per_die: bool) -> std::collections::BTreeMap<(u32, u32), u32> {
let mut first = std::collections::BTreeMap::new();
let Ok(entries) = std::fs::read_dir(root.join("sys/devices/system/cpu")) else {
return first;
};
for entry in entries.flatten() {
let Some(cpu) = entry.file_name().to_str().and_then(|n| n.strip_prefix("cpu")?.parse::<u32>().ok()) else {
continue;
};
let topology = entry.path().join("topology");
let Some(package) = read_u64(&topology.join("physical_package_id")).and_then(|p| u32::try_from(p).ok()) else {
continue;
};
let die = if per_die {
read_u64(&topology.join("die_id")).and_then(|d| u32::try_from(d).ok()).unwrap_or(0)
} else {
0
};
let lowest = first.entry((package, die)).or_insert(cpu);
*lowest = (*lowest).min(cpu);
}
first
}
/// Every RAPL domain's energy-status MSR under the filesystem root `root`, in
/// (package, die) order: each package on AMD and Hygon, each die of each
/// package on Intel ([`first_cpu_per_domain`]), read through the device file of
/// the domain's first CPU (`<root>/dev/cpu/<n>/msr`), at the unit its
/// power-unit MSR reads. Empty when the CPU is not one whose MSRs this crate
/// knows, when no CPU topology is listed, or when any domain's power-unit MSR
/// cannot be read or reads a zero unit — a figure that left a domain out would
/// miss its energy, and a zero unit is a hypervisor's stand-in, not a counter.
pub(crate) fn msr_packages_under(root: &Path) -> Vec<Package> {
let Ok(cpuinfo) = std::fs::read_to_string(root.join("proc/cpuinfo")) else {
return Vec::new();
};
let Some((vendor, family)) = cpu_vendor_family(&cpuinfo) else {
return Vec::new();
};
let Some((unit_register, register)) = msr_registers(vendor, family) else {
return Vec::new();
};
let per_die = vendor == "GenuineIntel";
let mut packages = Vec::new();
for ((index, die), cpu) in first_cpu_per_domain(root, per_die) {
let dir = root.join(format!("dev/cpu/{cpu}/msr"));
let Some(unit) = read_msr(&dir, unit_register) else {
return Vec::new();
};
let unit_shift = ((unit >> 8) & 0x1F) as u32;
if unit_shift == 0 {
return Vec::new();
}
let msr = Msr { register, unit_shift };
packages.push(Package { dir, index, die, max_energy_range_uj: Some(msr.range_uj()), msr: Some(msr) });
}
packages
}
/// This host's packages: the powercap package zones under `powercap`, or,
/// where it lists none, every package's energy-status MSR under the filesystem
/// root `root`.
fn packages_from(powercap: &Path, root: &Path) -> Vec<Package> {
let zones = packages_under(powercap);
if zones.is_empty() { msr_packages_under(root) } else { zones }
}
/// This host's packages: its powercap package zones, or its energy-status
/// MSRs where the kernel lists none. Off Linux, none.
fn host_packages() -> Vec<Package> {
if cfg!(target_os = "linux") { packages_from(Path::new(POWERCAP_ROOT), Path::new("/")) } else { Vec::new() }
}
/// One zone's energy between two reads, modulo its own wrap. A counter that went
@ -232,10 +444,18 @@ impl RaplCounter {
/// and the composite meter falls through instead of selecting a counter
/// whose every read errors, or reads zero, or leaves a socket out.
///
/// Where powercap lists no package zone, the counter is over every
/// package's energy-status MSR instead (see the module docs), under the
/// same rules: every package readable and live. Without the `msr` module or
/// `CAP_SYS_RAWIO` no MSR reads, and the counter is
/// [`CounterLiveness::Unavailable`].
///
/// The first call for a package set in a process runs the liveness probe on
/// the calling thread; later calls reuse the answer ([`rapl_liveness`]).
pub fn new() -> Self {
Self::with_root(POWERCAP_ROOT)
let packages = host_packages();
let liveness = package_liveness(&packages);
Self::given(packages, liveness)
}
/// Build over the package zones under an explicit powercap directory (a
@ -284,7 +504,7 @@ fn admits(liveness: CounterLiveness) -> bool {
/// package zone is [`CounterLiveness::Unavailable`] with no probe. Off Linux it
/// is [`CounterLiveness::Unavailable`], with no probe.
pub fn rapl_liveness() -> CounterLiveness {
package_liveness(&packages_under(Path::new(POWERCAP_ROOT)))
package_liveness(&host_packages())
}
/// The liveness of `packages`, every one of them, cached per package set.
@ -362,8 +582,10 @@ impl EnergyCounter for RaplCounter {
}
fn resolution_uj(&self) -> u64 {
// RAPL MSR native quantum.
1
// powercap reports microjoules; a package read from its MSR resolves
// one count at its unit (about 61 μJ at 2^-14 J), and the figure is
// only as fine as its coarsest package.
self.packages.iter().filter_map(|p| p.msr).map(Msr::count_uj).max().unwrap_or(1)
}
fn min_window_ns(&self) -> u64 {
@ -550,10 +772,13 @@ mod tests {
/// sub-zone, the control type and the MMIO interface are not.
#[test]
fn only_a_top_level_zone_named_package_n_is_a_package() {
for name in ["package-0", "package-1\n", "package-12"] {
for name in ["package-0", "package-1\n", "package-12", "package-0-die-0", "package-1-die-1\n"] {
assert!(is_package_name(name), "{name:?}");
}
for name in ["psys", "psys\n", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", ""] {
for name in [
"psys", "psys\n", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", "",
"package-0-die-", "package--die-0", "package-0-die-x", "package-0-core-0", "package-0-die-0-die-1",
] {
assert!(!is_package_name(name), "{name:?}");
}
assert_eq!(top_level_zone("intel-rapl:0"), Some(0));
@ -563,6 +788,20 @@ mod tests {
}
}
/// A multi-die Intel package as the kernel lists it: one top-level zone per
/// die, named `package-<p>-die-<d>`. Each is a package zone, and the figure
/// sums them.
#[test]
fn a_multi_die_package_sums_its_die_zones() {
let pc = Powercap::new("two-die-zones");
pc.zone("intel-rapl:0", "package-0-die-0", 1_000_000, Some(RANGE))
.zone("intel-rapl:1", "package-0-die-1", 2_000_000, Some(RANGE))
.zone("intel-rapl:2", "psys", 80_000_000, Some(RANGE));
let packages = packages_under(pc.root());
assert_eq!(listed(&packages), vec![(0, Some(RANGE)), (1, Some(RANGE))]);
assert_eq!(uj(&live_counter(&pc)), 3_000_000, "both dies, not psys");
}
/// A two-socket server: the figure is both packages' energy and nothing else
/// — not `psys` (which includes the packages, so would count them twice),
/// not a `dram` zone at the top level or under a package, not a core
@ -801,4 +1040,225 @@ mod tests {
assert_eq!(liveness, CounterLiveness::Frozen, "package 1 held its value");
assert!(took >= LIVENESS_BUDGET, "a frozen verdict took only {took:?}");
}
/// 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 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 `physical_package_id`, and the CPUs'
/// `dev/cpu/<n>/msr` files.
struct MsrRoot(PathBuf);
impl MsrRoot {
/// `package_of_cpu[n]` is CPU `n`'s package; no CPU lists a die.
fn new(tag: &str, cpuinfo: &str, package_of_cpu: &[u32]) -> Self {
let root = std::env::temp_dir().join(format!("jouleclaw-msr-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("proc")).expect("create proc");
std::fs::write(root.join("proc/cpuinfo"), cpuinfo).expect("write cpuinfo");
for (cpu, package) in package_of_cpu.iter().enumerate() {
let topology = root.join(format!("sys/devices/system/cpu/cpu{cpu}/topology"));
std::fs::create_dir_all(&topology).expect("create a CPU's topology");
std::fs::write(topology.join("physical_package_id"), format!("{package}\n")).expect("write its package");
}
Self(root)
}
/// `place_of_cpu[n]` is CPU `n`'s (package, die), each CPU listing
/// its `die_id`.
fn with_dies(tag: &str, cpuinfo: &str, place_of_cpu: &[(u32, u32)]) -> Self {
let packages: Vec<u32> = place_of_cpu.iter().map(|&(package, _)| package).collect();
let host = Self::new(tag, cpuinfo, &packages);
for (cpu, (_, die)) in place_of_cpu.iter().enumerate() {
let topology = host.0.join(format!("sys/devices/system/cpu/cpu{cpu}/topology"));
std::fs::write(topology.join("die_id"), format!("{die}\n")).expect("write its die");
}
host
}
/// 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}"));
std::fs::create_dir_all(&dir).expect("create dev/cpu/<n>");
let file = std::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 root(&self) -> &Path {
&self.0
}
}
impl Drop for MsrRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A two-socket Intel host (CPUs 0–1 on package 0, 2–3 on package 1)
/// with no powercap driver: each package read on its first CPU, at its
/// unit, and summed.
#[test]
fn a_two_socket_host_without_powercap_reads_each_packages_msr() {
let host = MsrRoot::new("two-socket", INTEL, &[0, 0, 1, 1]);
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 packages = msr_packages_under(host.root());
let devices: Vec<_> = packages.iter().map(|p| (p.index, p.dir.clone())).collect();
assert_eq!(
devices,
vec![(0, host.root().join("dev/cpu/0/msr")), (1, host.root().join("dev/cpu/2/msr"))],
"package 1 is read on its first CPU, 2"
);
assert!(packages.iter().all(|p| p.msr == Some(Msr { register: MSR_PKG_ENERGY_STATUS, unit_shift: 14 })));
assert!(packages.iter().all(|p| p.max_energy_range_uj == Some(262_144_000_000)), "2^32 counts of 2^-14 J");
let c = RaplCounter::given(packages, CounterLiveness::Live);
// 1,000 and 2,000 counts of 61.035 μJ, each floored.
assert_eq!(uj(&c), 61_035 + 122_070);
host.set(0, MSR_PKG_ENERGY_STATUS, 3_000).set(2, MSR_PKG_ENERGY_STATUS, 2_100);
assert_eq!(uj(&c), 183_105 + 128_173);
assert_eq!(c.resolution_uj(), 62, "one count, rounded up");
assert_eq!((c.provenance(), c.domain()), (Provenance::HwShunt, EnergyDomain::CpuPkg));
}
/// A multi-die Intel package (CPUs 0–1 on die 0, 2–3 on die 1): the
/// kernel scopes Intel's RAPL MSRs per die, so each die is a domain,
/// read on its own first CPU, and the figure sums both. Reading the
/// package's first CPU alone would count one die.
#[test]
fn each_die_of_a_multi_die_intel_package_is_read_and_summed() {
let host = MsrRoot::with_dies("two-die", INTEL, &[(0, 0), (0, 0), (0, 1), (0, 1)]);
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 packages = msr_packages_under(host.root());
let domains: Vec<_> = packages.iter().map(|p| (p.index, p.die, p.dir.clone())).collect();
assert_eq!(
domains,
vec![(0, 0, host.root().join("dev/cpu/0/msr")), (0, 1, host.root().join("dev/cpu/2/msr"))],
"die 1 is read on its first CPU, 2"
);
let c = RaplCounter::given(packages, CounterLiveness::Live);
assert_eq!(uj(&c), 61_035 + 122_070, "both dies' counts");
}
/// AMD's package energy counter is per package, so CPUs whose `die_id`
/// differs within one package (the dies of a Zen part) are one domain,
/// read once.
#[test]
fn an_amd_package_is_one_domain_whatever_its_dies() {
let host = MsrRoot::with_dies("amd-dies", AMD, &[(0, 0), (0, 1), (0, 2), (0, 3)]);
host.set(0, MSR_AMD_RAPL_POWER_UNIT, UNIT_16).set(0, MSR_AMD_PKG_ENERGY_STATUS, 65_536);
let packages = msr_packages_under(host.root());
assert_eq!(packages.len(), 1, "{packages:?}");
assert_eq!((packages[0].index, packages[0].die), (0, 0));
assert_eq!(uj(&RaplCounter::given(packages, CounterLiveness::Live)), 1_000_000);
}
/// 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("wrap", INTEL, &[0]);
host.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 0xFFFF_FFF0);
let c = RaplCounter::given(msr_packages_under(host.root()), CounterLiveness::Live);
let before = uj(&c);
host.set(0, MSR_PKG_ENERGY_STATUS, 0xABCD_0000_0000_0010);
// 32 counts of 61.035 μJ is 1,953.125 μJ.
assert_eq!(uj(&c) - before, 1_953);
}
/// AMD Zen reads its own power-unit and energy-status MSRs.
#[test]
fn amd_zen_reads_its_own_msrs() {
let host = MsrRoot::new("amd", AMD, &[0, 0]);
host.set(0, MSR_AMD_RAPL_POWER_UNIT, UNIT_16).set(0, MSR_AMD_PKG_ENERGY_STATUS, 65_536);
let packages = msr_packages_under(host.root());
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].msr, Some(Msr { register: MSR_AMD_PKG_ENERGY_STATUS, unit_shift: 16 }));
let c = RaplCounter::given(packages, CounterLiveness::Live);
assert_eq!(uj(&c), 1_000_000, "65,536 counts of 2^-16 J is one joule");
assert_eq!(c.resolution_uj(), 16);
}
/// No MSR counter where the 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, or where a package's device file is missing.
#[test]
fn a_zero_unit_an_unknown_cpu_or_a_missing_device_is_no_counter() {
let zero = MsrRoot::new("zero-unit", INTEL, &[0]);
zero.set(0, MSR_RAPL_POWER_UNIT, 0).set(0, MSR_PKG_ENERGY_STATUS, 0);
assert!(msr_packages_under(zero.root()).is_empty(), "a zero unit");
let arm = MsrRoot::new("arm", "processor\t: 0\nCPU implementer\t: 0x41\n", &[0]);
assert!(msr_packages_under(arm.root()).is_empty(), "no vendor_id");
let netburst = MsrRoot::new("family-15", "vendor_id\t: GenuineIntel\ncpu family\t: 15\n", &[0]);
netburst.set(0, MSR_RAPL_POWER_UNIT, UNIT_14);
assert!(msr_packages_under(netburst.root()).is_empty(), "not family 6");
let missing = MsrRoot::new("missing-device", INTEL, &[0, 1]);
missing.set(0, MSR_RAPL_POWER_UNIT, UNIT_14);
assert!(msr_packages_under(missing.root()).is_empty(), "package 1 has no device file");
}
/// Powercap zones are preferred; the MSRs are read only where powercap
/// lists no package.
#[test]
fn powercap_is_preferred_and_the_msrs_are_the_fallback() {
let host = MsrRoot::new("fallback", INTEL, &[0]);
host.set(0, MSR_RAPL_POWER_UNIT, UNIT_14).set(0, MSR_PKG_ENERGY_STATUS, 5);
let zones = two_socket_server("fallback-zones");
let preferred = packages_from(zones.root(), host.root());
assert_eq!(preferred.len(), 2);
assert!(preferred.iter().all(|p| p.msr.is_none()), "powercap's zones were listed");
let empty = Powercap::new("fallback-empty");
let fallback = packages_from(empty.root(), host.root());
assert_eq!(fallback.len(), 1);
assert!(fallback[0].msr.is_some(), "powercap listed no package, so the MSR is read");
}
/// The production probe over MSR packages: live when every package's
/// count keeps advancing, frozen when one holds.
#[test]
fn the_production_probe_judges_msr_packages_by_the_same_rule() {
let run = |tag: &str, moving: &[u32]| {
let host = MsrRoot::new(tag, INTEL, &[0, 1]);
for cpu in [0, 1] {
host.set(cpu, MSR_RAPL_POWER_UNIT, UNIT_14).set(cpu, MSR_PKG_ENERGY_STATUS, 1_000_000);
}
let packages = msr_packages_under(&host.0);
let mut count = 1_000_000u64;
probe_packages_stepping(&packages, || {
count += 18_432; // 45 W over a 25 ms tick, in 61.035 μJ counts
for &cpu in moving {
host.set(cpu, MSR_PKG_ENERGY_STATUS, count);
}
})
};
assert_eq!(run("probe-both", &[0, 1]), CounterLiveness::Live);
assert_eq!(run("probe-one", &[0]), CounterLiveness::Frozen, "package 1 held its count");
}
}
}

View file

@ -1,11 +1,15 @@
//! Linux RAPL against the hardware (feature `rapl`): this host's powercap
//! counters, read by the meter and by an oracle the tests declare here.
//! counters, or its energy-status MSRs where powercap lists no package, read by
//! the meter and by an oracle the tests declare here.
//!
//! In a test binary of its own, so the RAPL probe runs in this process, and
//! the tests take one lock: each burns CPU and reads machine-wide counters.
//! The oracle reads `/sys/class/powercap` itself — every zone's `name`,
//! `energy_uj` and `max_energy_range_uj` — and applies the sustained-advance
//! rule to the top-level `package-<n>` zones on its own. None of these tests
//! rule to the top-level package zones (`package-<n>`, or `package-<n>-die-<m>`
//! on a multi-die package) on its own. Where powercap lists no package it reads
//! the RAPL MSRs itself (`oracle::msr`), each domain on its first CPU: per
//! package on AMD and Hygon, per die on Intel. None of these tests
//! passes without asserting: where the counters cannot be read (no powercap;
//! a CI container, whose `/sys/devices/virtual/powercap` Docker masks), the
//! liveness must say so and agree with the oracle; where they are live, both
@ -118,9 +122,14 @@ mod oracle {
z.dir.strip_prefix("intel-rapl:").is_some_and(digits)
}
/// A package zone: top-level, named `package-<n>`.
/// A package zone: top-level, named `package-<n>`, or `package-<n>-die-<m>`
/// (one zone per die of a multi-die Intel package).
pub fn is_package(z: &Zone) -> bool {
top_level(z) && z.name.as_deref().and_then(|n| n.strip_prefix("package-")).is_some_and(digits)
top_level(z)
&& z.name.as_deref().and_then(|n| n.strip_prefix("package-")).is_some_and(|n| match n.split_once("-die-") {
Some((package, die)) => digits(package) && digits(die),
None => digits(n),
})
}
/// The platform zone.
@ -190,23 +199,52 @@ mod oracle {
}
}
/// Sample every package's `energy_uj`, burning 25 ms of this thread's CPU
/// between samples (capped at 500 ms of wall time), until every package
/// has advanced in at least five intervals, at least 80% of those
/// Sample every package's `energy_uj` under `root`, burning 25 ms of this
/// thread's CPU between samples (capped at 500 ms of wall time), until every
/// package has advanced in at least five intervals, at least 80% of those
/// observed, over at least 300 ms; or one second has passed with at least
/// five intervals; or five seconds have.
pub fn watch(root: &Path) -> Watch {
let all = zones(root);
let packages: Vec<&Zone> = all.iter().filter(|z| is_package(z)).collect();
let names: Vec<String> = packages.iter().map(|z| z.dir.clone()).collect();
let none = |verdict| Watch { verdict, packages: names.clone(), intervals: 0, advances: Vec::new(), window: Duration::ZERO };
if packages.is_empty() {
let readable = all.iter().any(|z| top_level(z) && z.energy_uj.is_some());
return none(if readable { Verdict::NoPackage } else { Verdict::Unreadable });
return Watch::none(if readable { Verdict::NoPackage } else { Verdict::Unreadable }, names);
}
watch_with(names, || read(root, &packages))
}
/// [`watch`] over this host: its powercap package zones, or, where powercap
/// lists none, its RAPL MSRs ([`msr::domains`]), sampled the same way.
pub fn watch_host() -> Watch {
let root = Path::new(POWERCAP);
if zones(root).iter().any(is_package) {
return watch(root);
}
match msr::domains() {
Some(domains) => {
let names = domains.iter().map(|d| d.name.clone()).collect();
watch_with(names, || msr::read_all(&domains))
}
None => {
let w = watch(root);
Watch::none(w.verdict, w.packages)
}
}
}
impl Watch {
fn none(verdict: Verdict, packages: Vec<String>) -> Watch {
Watch { verdict, packages, intervals: 0, advances: Vec::new(), window: Duration::ZERO }
}
}
/// The sustained-advance run over the counters `read` returns, one per name.
fn watch_with(names: Vec<String>, mut read: impl FnMut() -> Option<Vec<u64>>) -> Watch {
let started = Instant::now();
let Some(mut prev) = read(root, &packages) else { return none(Verdict::Unreadable) };
let mut advances = vec![0u32; packages.len()];
let Some(mut prev) = read() else { return Watch::none(Verdict::Unreadable, names) };
let mut advances = vec![0u32; names.len()];
let mut intervals = 0u32;
let live = |adv: &[u32], n: u32, w: Duration| {
w >= Duration::from_millis(300) && adv.iter().all(|&a| a >= 5 && u64::from(a) * 5 >= u64::from(n) * 4)
@ -217,7 +255,7 @@ mod oracle {
break;
}
burn(Duration::from_millis(25), Duration::from_millis(500));
if let Some(now) = read(root, &packages) {
if let Some(now) = read() {
intervals += 1;
for ((a, n), p) in advances.iter_mut().zip(&now).zip(&prev) {
*a += u32::from(n > p);
@ -230,6 +268,96 @@ mod oracle {
Watch { verdict, packages: names, intervals, advances, window }
}
/// The oracle's own reading of the RAPL energy-status MSRs, for a host
/// whose powercap lists no package: each domain's count through the `msr`
/// driver's device file of its first CPU. A domain is a package on AMD and
/// Hygon (a per-package counter) and a die of a package on Intel (the
/// kernel's die-scope RAPL MSRs).
pub mod msr {
use super::number;
use std::os::unix::fs::FileExt;
use std::path::{Path, PathBuf};
/// One RAPL domain's energy-status MSR.
pub struct Domain {
pub name: String,
device: PathBuf,
register: u64,
shift: u32,
}
fn read(device: &Path, register: u64) -> Option<u64> {
let file = std::fs::File::open(device).ok()?;
let mut raw = [0u8; 8];
file.read_exact_at(&mut raw, register).ok()?;
Some(u64::from_le_bytes(raw))
}
/// Every domain whose power-unit and energy-status MSRs read with a
/// non-zero unit; `None` when the CPU's MSRs are not known here, no
/// topology is listed, or any domain cannot be read.
pub fn domains() -> Option<Vec<Domain>> {
let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").ok()?;
let field = |key: &str| {
cpuinfo.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim() == key).map(|(_, v)| v.trim()))
};
let family: u32 = field("cpu family")?.parse().ok()?;
let (unit, energy, per_die) = match field("vendor_id")? {
"GenuineIntel" if family == 6 => (0x606, 0x611, true),
"AuthenticAMD" | "HygonGenuine" if family >= 0x17 => (0xC001_0299, 0xC001_029B, false),
_ => return None,
};
let mut first = std::collections::BTreeMap::<(u64, u64), u32>::new();
for entry in std::fs::read_dir("/sys/devices/system/cpu").ok()?.flatten() {
let Some(cpu) = entry.file_name().to_str().and_then(|n| n.strip_prefix("cpu")?.parse::<u32>().ok()) else {
continue;
};
let topology = entry.path().join("topology");
let Some(package) = number(topology.join("physical_package_id")) else { continue };
let die = if per_die { number(topology.join("die_id")).unwrap_or(0) } else { 0 };
let lowest = first.entry((package, die)).or_insert(cpu);
*lowest = (*lowest).min(cpu);
}
if first.is_empty() {
return None;
}
first
.into_iter()
.map(|((package, die), cpu)| {
let device = PathBuf::from(format!("/dev/cpu/{cpu}/msr"));
let shift = ((read(&device, unit)? >> 8) & 0x1F) as u32;
if shift == 0 {
return None;
}
read(&device, energy)?;
Some(Domain { name: format!("msr package-{package}-die-{die} (cpu {cpu})"), device, register: energy, shift })
})
.collect()
}
/// One domain's count, in µJ.
fn uj(d: &Domain) -> Option<u64> {
read(&d.device, d.register).map(|raw| ((u128::from(raw & 0xFFFF_FFFF) * 1_000_000) >> d.shift) as u64)
}
/// Every domain's count, in µJ; `None` if one fails.
pub fn read_all(domains: &[Domain]) -> Option<Vec<u64>> {
domains.iter().map(uj).collect()
}
/// The domains' summed energy between two reads, each modulo its 2^32 counts.
pub fn energy(domains: &[Domain], before: &[u64], after: &[u64]) -> u64 {
domains
.iter()
.zip(before.iter().zip(after))
.map(|(d, (&b, &a))| {
let range = (((1u128 << 32) * 1_000_000) >> d.shift) as u64;
super::delta(b, a, Some(range)).expect("an MSR count above its range")
})
.sum()
}
}
/// The zones under `root`, as one line for the log.
pub fn listing(root: &Path) -> String {
zones(root)
@ -379,7 +507,7 @@ use oracle::Verdict;
fn the_rapl_liveness_agrees_with_the_oracle_and_gates_the_counter() {
let _one = serial();
let liveness = rapl_liveness();
let watch = oracle::watch(Path::new(oracle::POWERCAP));
let watch = oracle::watch_host();
let counter = RaplCounter::new();
let composite = meter::detect_meter();
eprintln!(
@ -412,13 +540,37 @@ fn a_cpu_burn_reads_inside_the_package_only_bracket() {
let _one = serial();
let counter = RaplCounter::new();
if !counter.is_available() {
let watch = oracle::watch(Path::new(oracle::POWERCAP));
let watch = oracle::watch_host();
eprintln!("rapl burn: counter {:?}; oracle {}", counter.liveness(), watch.summary());
assert_ne!(watch.verdict, Verdict::Live, "the oracle saw live packages, yet the counter is not available");
assert!(counter.read().is_err());
return;
}
let all = oracle::zones(Path::new(oracle::POWERCAP));
if !all.iter().any(oracle::is_package) {
// No powercap package: the counter reads the MSRs, and so does the oracle,
// outer and inner around the meter's reads.
let domains = oracle::msr::domains().expect("the counter reads the MSRs, so the oracle can");
let read = || oracle::msr::read_all(&domains).expect("an oracle MSR read");
let o0 = read();
let r0 = counter.read().expect("a live counter reads");
let o1 = read();
let cpu = oracle::burn(Duration::from_millis(300), Duration::from_secs(10));
let o2 = read();
let r1 = counter.read().expect("a live counter reads");
let o3 = read();
for r in [&r0, &r1] {
assert_eq!((r.domain, r.provenance), (EnergyDomain::CpuPkg, Provenance::HwShunt), "{r:?}");
}
let (uj, inner, outer) =
(r1.uj - r0.uj, oracle::msr::energy(&domains, &o1, &o2), oracle::msr::energy(&domains, &o0, &o3));
let names: Vec<&str> = domains.iter().map(|d| d.name.as_str()).collect();
eprintln!("rapl burn (MSR {names:?}): {cpu:?} of thread CPU; meter {uj} µJ; oracle inner {inner} outer {outer} µJ");
assert!(inner > 0, "the domains did not advance over the burn");
assert!(inner <= uj && uj <= outer, "the figure {uj} µJ is outside the oracle's MSR bracket [{inner}, {outer}]");
assert!(cpu >= Duration::from_millis(300), "the burn got only {cpu:?} of CPU");
return;
}
let mut cpu = Duration::ZERO;
let (b, r0, r1) = oracle::around(
oracle::bracket_set(&all),