open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs
Transaction Science 45f91fe0f6
All checks were successful
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 43s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 11s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 38s
wai-quantum WebGPU member / does this change touch it (pull_request) Successful in 10s
standards workspaces build + test / arl-rs (arl/arl-rs, arl-rs, linux) (pull_request) Successful in 1m16s
energy meters on real hardware / sink decode table · per-task energy and the GPU rail on macOS (pull_request) Successful in 2m45s
standards workspaces build + test / joulespec-proofs (joulespec/joulespec-rs, joulespec-proofs, true, macos) (pull_request) Successful in 2m9s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m9s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m18s
energy meters on real hardware / sink decode table · RAPL on jetson-hub (NVML surveyed) (pull_request) Successful in 3m1s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 2m4s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 57s
standards workspaces build + test / pool-rs (pool/pool-rs, pool-rs, linux) (pull_request) Successful in 59s
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Successful in 1m23s
standards workspaces build + test / sense-rs (sense/sense-rs, sense-rs, linux) (pull_request) Successful in 48s
standards workspaces build + test / proof-rs (proof/conformance/v1/joulespec.toml, proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 1m4s
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Successful in 3m8s
standards workspaces build + test / eoc-rs (eoc/eoc-rs, -p eoc-meter --features cuda, eoc-rs, linux) (pull_request) Successful in 4m40s
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Successful in 1m42s
standards workspaces build + test / joulespec-rs (true, joulespec/joulespec-rs, true, joulespec-rs, linux) (pull_request) Successful in 2m30s
standards workspaces build + test / smart-byte-rs (smart-byte/smart-byte-rs, smart-byte-rs, linux) (pull_request) Successful in 4m25s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, -p sandbox-tee --features amd-kds,intel-pcs, sandbox-rs, linux) (pull_request) Successful in 5m19s
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness (pull_request) Successful in 6m30s
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Successful in 11m38s
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Successful in 10m56s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 11m21s
wai-quantum WebGPU member / member · GPU, Vulkan (jetson-hub) (pull_request) Successful in 17m37s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m13s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 1m3s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m1s
standards workspaces build + test / map-rs (map-conformance, map/map-rs, map-rs, linux-arm64) (pull_request) Successful in 1m45s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 3m32s
wai-quantum WebGPU member / member · GPU, Vulkan (thor) (pull_request) Successful in 24m45s
standards workspaces build + test / aac-rs (aac-conformance, aac/aac-rs, aac-rs, linux-arm64) (pull_request) Successful in 5m35s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 4m5s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 6m38s
jouleclaw-energy: a Mac's whole-machine power from the SMC (EnergyDomain::System, ModelBased)
On Apple Silicon under macOS 27, IOReport's CPU rails are frozen for an
unprivileged reader, so detect_meter fell back to the load-average estimator:
a guess, graded Estimator. Every Mac still publishes its total power in the SMC
key PSTR, which any process reads through the AppleSMCKeysEndpoint IOKit user
client with no root.

New feature `smc` (macOS), module apple_smc: SmcSystemCounter integrates PSTR
over time.
- Domain: a new EnergyDomain::System, the whole machine (everything its DC
  input feeds: SoC, memory, storage, fans, the rest of the board; not the
  power supply's AC-to-DC loss). It holds SocTotal and every other domain.
- Grade: ModelBased, like NVML's power.draw integral. PSTR is a power reading,
  not an energy counter, so no liveness probe can vouch for its integral.
- Sampling: one thread per process reads PSTR every 100 ms and integrates the
  value it holds, so a figure does not depend on how often its caller reads; a
  read between polls extends the integral at the held value, which the next
  poll integrates over the same time, so readings never run backwards.
- Checks: PSTR must be a 4-byte float and be seen to refresh before the
  counter is available (the first construction waits about a second); samples
  at 0 W or less, above 1.5 kW, or NaN are discarded and counted; a value held
  10 s gives no figure.
- Minimum window 2 s: PSTR refreshes about once a second (median gap 1.0 s on
  an M3 Ultra Mac Studio and an M5 Max MacBook Pro, macOS 27.2) and trails a
  step in load by about one refresh.

detect_meter puts it after a live IOReport counter (a hardware reading over a
narrower domain) and before the load-average estimator. Its figure holds the
work and whatever else the machine powered, so it can read high, never low.
mesh-meter's exhaustive covers_whole_request counts System as covering the
whole request. SPEC 1.3 lists sampled vendor power integrated over time under
ModelBased.

Measured with the energy_probe example (2.5 s CPU churn, unprivileged): the
M3 Ultra Mac Studio 299.9 J, 120.0 W; the M5 Max MacBook Pro on AC 160.9 J,
64.4 W. On the Studio PSTR equals the DC input (PDTR); on the MacBook Pro, on
AC with the battery not charging, it read 51.7 W against 54.9 W at the input.

Tests: the integral, monotonic reads, implausible samples, staleness and the
sampler's start are driven with scripted samples and times; a hardware test
meters a CPU burn from the real SMC and prints "apple-smc: measured", which a
new macOS CI step greps under --features smc and ioreport,smc. The clippy step
lints both. Passing on the M3 Ultra (smc, ioreport,smc, ioreport; clippy clean
in default, ioreport, smc, ioreport,smc; mesh-meter with hw-meter-apple) and on
Linux x86_64 (rapl,nvml; default clippy; mesh-meter with hw-meter-rapl).

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

176 lines
7 KiB
Rust

//! Cross-platform energy probe — read the best available hardware counter on
//! this host and print its energy delta with its honest provenance.
//!
//! The point of the probe: show whether on real hardware JouleClaw reports a
//! `HwShunt` / `ModelBased` reading or an `Estimator` fallback, and label the
//! figure accordingly. Build with the feature that matches the host's counter:
//!
//! # NVIDIA discrete GPU (Linux or Windows — loads libnvidia-ml / nvml.dll):
//! cargo run -p jouleclaw-energy --release --example energy_probe --features nvml
//!
//! # Intel/AMD x86 on Linux (powercap sysfs RAPL):
//! cargo run -p jouleclaw-energy --release --example energy_probe --features rapl
//!
//! # Apple Silicon (IOReport Energy Model when its CPU rail is live, else the
//! # load-avg estimator, with the reason printed):
//! cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport
//!
//! # Mac, whole machine (the SMC's `PSTR` integrated over time, ModelBased;
//! # with `ioreport` too, after a live IOReport counter):
//! cargo run -p jouleclaw-energy --release --example energy_probe --features smc
//!
//! It samples the counter across a ~2 s window (driving a small CPU workload so
//! the package/SoC is not fully idle) and prints the integrated microjoules. For
//! the integrating counters (NVML) the repeated reads during the window are what
//! build the energy integral.
use jouleclaw_energy::EnergyCounter;
use std::time::{Duration, Instant};
/// Pick the first available real counter for the features this binary was built
/// with. Returns the counter and a short label.
fn pick_counter() -> Option<(&'static str, Box<dyn EnergyCounter>)> {
#[cfg(feature = "nvml")]
{
let c = jouleclaw_energy::nvml::NvmlCounter::new();
if c.is_available() {
// Report which NVML path reads — the cumulative-energy counter,
// only where it was observed live (HwShunt), or the power-draw
// integral (ModelBased) — and the counter's liveness.
println!(
"nvml path : {} (counter liveness: {:?})",
if c.uses_true_counter() {
"nvmlDeviceGetTotalEnergyConsumption (live cumulative counter, HwShunt)"
} else {
"power_usage() integration (ModelBased)"
},
c.liveness()
);
return Some(("nvml (NVIDIA GPU shunt)", Box::new(c)));
}
}
#[cfg(feature = "rapl")]
{
let c = jouleclaw_energy::rapl::RaplCounter::new();
println!("rapl : liveness {:?}", c.liveness());
if c.is_available() {
return Some(("rapl (x86 package MSR)", Box::new(c)));
}
}
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
// Prefer the real IOReport energy counter (HwShunt, like RAPL and NVML).
// It is unavailable unless IOReport opens AND its CPU energy rail
// advances under load — a counter that is present but not live gives no
// hardware-graded figure; say which before falling back to the load-avg
// estimator.
let c = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
if c.is_available() {
return Some(("apple-ioreport (Energy Model)", Box::new(c)));
}
println!("ioreport : declined ({:?})", c.status());
}
#[cfg(all(feature = "smc", target_os = "macos"))]
{
// The whole machine's power from the SMC, integrated over time: a vendor
// power reading, so ModelBased, over the System domain.
let c = jouleclaw_energy::apple_smc::SmcSystemCounter::new();
if c.is_available() {
println!("smc : PSTR {:.1} W now", c.watts().unwrap_or(0.0));
return Some(("apple-smc (SMC PSTR, whole machine)", Box::new(c)));
}
println!("smc : unavailable ({})", c.unavailable_reason().unwrap_or("?"));
}
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
let c = jouleclaw_energy::apple::AppleSiliconMeter::new();
if c.is_available() {
return Some(("apple (load-avg estimator)", Box::new(c)));
}
}
None
}
/// A small CPU workload so the host is not fully idle during the window.
fn churn(deadline: Instant) -> u64 {
let mut acc: u64 = 0;
let mut x: u64 = 0x9E3779B97F4A7C15;
while Instant::now() < deadline {
for _ in 0..200_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
acc = acc.wrapping_add(x);
}
}
acc
}
fn main() {
println!("== JouleClaw energy probe ==");
let Some((label, counter)) = pick_counter() else {
eprintln!(
"no hardware energy counter available on this host/build.\n\
rebuild with --features nvml|rapl|ioreport|smc for this platform."
);
std::process::exit(1);
};
println!("counter : {label}");
println!("provenance : {}", counter.provenance());
println!("domain : {:?}", counter.domain());
println!("resolution : {} uj", counter.resolution_uj());
println!("min window : {} ns", counter.min_window_ns());
// Prime the counter (first read seeds the integrator / cumulative baseline).
let _ = counter.read();
let before = match counter.read() {
Ok(r) => r,
Err(e) => {
eprintln!("counter read failed: {e}");
std::process::exit(1);
}
};
// Integrate over a ~2 s window, or the counter's minimum window if longer
// (plus a margin): churn the CPU and re-read every ~200 ms so the
// integrating counters accumulate the wall-power integral.
let floor = Duration::from_nanos(counter.min_window_ns());
let window = Duration::from_secs(2).max(floor + Duration::from_millis(500));
let start = Instant::now();
let mut checksum: u64 = 0;
while start.elapsed() < window {
let step = (Instant::now() + Duration::from_millis(200)).min(start + window);
checksum = checksum.wrapping_add(churn(step));
let _ = counter.read();
}
let after = match counter.read() {
Ok(r) => r,
Err(e) => {
eprintln!("counter read failed: {e}");
std::process::exit(1);
}
};
let elapsed = start.elapsed().as_secs_f64();
let delta_uj = after.uj.saturating_sub(before.uj);
let joules = delta_uj as f64 / 1_000_000.0;
// An estimator's figure is not a measurement; say which one this is.
if after.provenance == jouleclaw_energy::Provenance::Estimator {
println!("--- estimated ---");
} else {
println!("--- measured ---");
}
println!("window : {:.3} s", elapsed);
println!(
"energy : {} uj ({:.3} J) avg {:.2} W [{}]",
delta_uj,
joules,
if elapsed > 0.0 { joules / elapsed } else { 0.0 },
after.provenance,
);
// Keep the workload from being optimized away.
if checksum == 0 {
eprintln!("(workload checksum was zero — unexpected)");
}
}