open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md
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

13 KiB
Raw Permalink Blame History

Measured energy — cross-platform verification

JouleClaw energy readings carry an honest Provenance: HwShunt (a hardware reading: a shunt, or a vendor energy counter — RAPL, NVML, Apple IOReport — while it is live for the domain it claims), ModelBased (a vendor freq/voltage/utilisation model), or Estimator (a static cost model, used where no live counter exists).

A vendor energy counter's reading is a real reading; what has to be established is its provenance — which counter, which domain, and whether it was live — not its accuracy. A counter that is present but not live (frozen, or republished in lumps) gives no HwShunt figure: the meter falls back to its estimator.

The goal is that on real hardware a reading is never an Estimator. IOReport does not meet it on Apple Silicon under macOS 27 (below). Built with smc, such a Mac is metered as a whole machine instead, ModelBased (below). The energy_probe example reads the best available counter for its build features, integrates over a ~2 s CPU-churn window, and prints the measured microjoules with provenance:

cargo run -p jouleclaw-energy --release --example energy_probe --features nvml    # NVIDIA (Linux/Windows)
cargo run -p jouleclaw-energy --release --example energy_probe --features rapl    # x86 (Linux)
cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport # Apple Silicon
cargo run -p jouleclaw-energy --release --example energy_probe --features smc      # Mac, whole machine

Verified on real hardware

Platform Counter Domain Provenance Reading (2 s churn)
Apple Silicon (this dev host; chip and macOS version not recorded) IOReport "Energy Model" SoC total ModelBased (the tag then) 74.1 J · 25.5 W
Windows 11 + GeForce RTX 2060 NVML (nvml.dll) GPU HwShunt 18.8 J · 9.4 W
Linux x86 + Tesla T4 (AWS g4dn.metal) NVML (libnvidia-ml) GPU HwShunt 148.4 J · 71.8 W
Linux x86, Xeon Platinum 8259CL (AWS g4dn.metal) RAPL powercap CPU package HwShunt 154.2 J · 77.1 W (1 µJ res)
Mac Studio, M3 Ultra, macOS 27.2 SMC PSTR, integrated whole machine (System) ModelBased 299.9 J · 120.0 W (2.5 s)
MacBook Pro, M5 Max, macOS 27.2, on AC SMC PSTR, integrated whole machine (System) ModelBased 160.9 J · 64.4 W (2.5 s)

Notes:

  • Apple — dated note (2026-09-30). The Apple row was committed on 2026-06-12 and predates macOS 27. Which chip and which macOS version it was read on were not recorded, so it cannot be reproduced or re-checked; it was tagged ModelBased, the grade IOReport carried then, and no liveness check existed. It stands as a historical figure only. A live IOReport reading is now graded HwShunt, like RAPL and NVML.
  • Apple on macOS 27.0.1. The IOReport counter (apple_ioreport) opens with no sudo, but an unprivileged reader does not get live CPU energy: every CPU rail in the Energy Model group holds its value under minutes of full CPU load, moving only in batch refreshes tens of minutes apart. Observed on an M5 Max (CPU Energy frozen while GPU Energy advances) and on an M3 Ultra (all 645 Energy Model rails frozen, CPU, DRAM and display included). So the counter checks that its CPU rail is live before it reads (ioreport_status()), declines when it is not, and the load-avg estimator meters the host instead: on those hosts a Joule Code L3 inference emits a receipt tagged Estimator. Where the rail is live, the receipt is tagged HwShunt. The load-avg estimator is coarse: on the dev host it once guessed ~10.7 W for the churn above, against 25.5 W read from IOReport.
  • NVML is one cross-platform counter (nvml) — the same code reports HwShunt on Windows and Linux; it loads the driver's NVML library by name on each OS.
  • RAPL (rapl) needs the powercap sysfs interface, which is exposed on bare-metal Linux (not most virtualised guests). On modern kernels intel-rapl:*/energy_uj is root-readable only (PLATYPUS mitigation); the counter reads it when run with sufficient privilege.

Apple Silicon, per process (macOS 27.0.1, unprivileged)

For work that runs in this process — its threads, and GPU work it dispatches — two live sources replace the frozen rails:

Counter Source Domain Provenance
process-cpu (process) proc_pid_rusage ri_energy_nj this process's CPU HwShunt while live
ProcessGpuCounter (process_gpu, ioreport) this process's accumulatedGPUTime (I/O Registry IOAccelerator user clients); IOReport GPU Energy (nJ) this process's GPU HwShunt: 0 µJ exact with no GPU time; the rail at ≥ 99% of every client's GPU time; else no figure

The CPU count is read only once the reading thread is seen to have left its core. The kernel adds a thread's energy to its process's count when the thread leaves a core, and a process reading itself does not make the reading thread leave, so a read from a thread that has been running is short of what it spent since it last left: up to a scheduling quantum, missing from a span that ends there and, spent before it, inside a span that starts there. process-cpu sleeps before each read of its own process (50 µs, then 200 µs, 1 ms, 5 ms) until the reading thread's own energy count (proc_pidinfo, PROC_PIDTHREADCOUNTS) rose across a sleep; a read whose thread was not seen to leave is no reading (NotFolded), and its span falls back to the host meter. tests/process_cpu_short_span.rs holds 1 ms spans, and sleeps metered straight after a burn, against a reader that folds by its own rule, 40 spans of each a run. Without the sleep: on an M5 Max at a load average of 90 to 135, 25 to 31 of the 1 ms spans read nothing or under three quarters of the work, and 25 to 31 of the sleeps read the burn before them (up to 18 mJ); on an idle M3 Ultra, all 40 of the 1 ms spans read nothing and all 40 of the sleeps read the whole burn before them (42 to 53 mJ). With it, every span read at least 95% of the work on the loaded M5 Max and 97% on the quiet M3 Ultra, and no sleep more than 0.5% of the burn; under 40 busy loops on the M3 Ultra (220 runs) every span read at least 84% (the rest is the two readers' own reads) and no sleep more than 2%. A read costs about 80 µs on the quiet M3 Ultra, about 200 to 330 µs at the median under the busy loops, and 81 to 154 µs at the median on the loaded M5 Max (1 µs without the sleep).

The GPU rail is machine-wide and is never split by GPU time: energy is not proportional to it. On this host a GGUF decode drew ~1.9 W per GPU-second alone while another session's job drew ~17 W, so a time-share split charged the decode 3–15× its sole-user GPU energy; and the decode's own GPU time grew 5.4× (1.83 s to 9.81 s for the same 63 tokens) beside a compute burner. A shared GPU gives no figure (GpuShared), nor does a GPU client that vanished mid-span while this process used the GPU, nor a rail that held while the GPU was busy.

span::ProcessSpanMeter adds the two: Whole (DRAM excluded, as in a RAPL package figure), Partial for work declared to use the ANE, which has no live unprivileged source. Where the GPU has no figure the span is the CPU figure alone, HwShunt, Partial, with why; only a CPU counter with no figure falls back to the host meter. The GPU liveness probe needs a GPU load from the caller (jouleclaw_backend_apple::gpu_liveness_load).

Measured on an M5 Max (2026-10-04):

  • tests/process_gpu.rs, quiet GPU: a 1.5 s compute burn read 39.860 J, this process 1603.6 ms of 1603.6 ms GPU time, equal to the test's own IOReport read and ioreg's times. Beside a burning child: GpuShared (491.3 of 1179.8 ms); spans with no GPU work of this process's were 0 µJ exactly, beside the child and across its exit.
  • gguf_energy (jouleclaw-runtime example), tinyllama-1.1b Q8_0 with Q8_PACKED=1 (decode GEMVs on the GPU), 63 decode tokens:
    • sole GPU user: 0.128 J/token = CPU 0.069 + GPU 0.060, HwShunt, Whole (1830.0 ms of 1836.1 ms GPU time, 99.67%; 3.77 J rail), 71.5 ms/token;
    • beside a compute burner: CPU alone 0.053 J/token, HwShunt, Partial: the GPU was shared (9.81 s of 13.98 s of GPU time), 225 ms/token.
  • The rail at times holds 300–600 ms under load and catches up in one step, and on a quiet GPU it did not change at all for 4 s, so a snapshot reads it on its next change, or as it stands once no client accrues GPU time. On this host 18% of 5 s windows (24% of 10 s) saw a GPU client vanish.

tests/process_gpu.rs lists the registry with ioreg before, around and after each span of GPU work it checks against the oracle or through the span meter, independently of the counter, and confirms each refusal there: the client the counter saw vanish is one the registry listed under the same pid (gone), or whose fall it shows inside the span (gone back), and the refusal names the client the meter's rule picks. A reading of this process's own GPU time that the registry contradicts fails the test at once. A span whose vanished client the registry never listed, or whose readings of another process's clients it cannot explain, is taken again later; none it can vouch for within 90 s fails the test as inconclusive. Agreement with the oracle is checked over the clients present at both ends, with any vanished client taken out of both snapshots. The rail is checked against the test's own read on each span checked against the oracle (the 1.5 s burn and the contended burn) whose rail was current at both ends, a shared span included. On a shared host, the 1.5 s burn gives a figure or GpuShared, depending on what else runs on the GPU.

A Mac as a whole machine (SMC PSTR, unprivileged)

The SMC publishes the machine's total power as the key PSTR, and any process reads it through the AppleSMCKeysEndpoint IOKit user client, with no root. apple_smc (feature smc) integrates it over time:

Domain System: everything the machine's DC input feeds (SoC, memory, storage, fans, the rest of the board), not the power supply's AC-to-DC loss
Grade ModelBased: a sampled power value integrated over time, like NVML's power.draw integral, never HwShunt
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
Checks PSTR must be a 4-byte float and be seen to refresh before the counter is available; samples at 0 W or less, above 1.5 kW, or not a number are discarded; a value held 10 s gives no figure
Minimum window 2 s

Measured on macOS 27.2:

  • Refresh. PSTR changes about once a second on an M3 Ultra Mac Studio and an M5 Max MacBook Pro (median gap between changes 1.0 s on both), and trails a step in CPU load by about one refresh. Hence the 2 s minimum window.
  • Coverage. On the Mac Studio PSTR equals the DC input, PDTR. On the MacBook Pro on AC with the battery not charging, PSTR read 51.7 W against 54.9 W at the DC input: it is the system's draw, not the input's.
  • Not established: whether each value averages the second before it or samples one point. Other work on the dev Mac swings it between about 35 and 64 W, which hides a 4-core burst of 200 ms. The ModelBased grade and the 2 s window hold either way.

detect_meter puts it after a live IOReport counter, a hardware reading over a narrower domain, and before the load-average estimator, which reads no power. On both Macs above IOReport's CPU rails are frozen, so with smc the host meter is the SMC: a figure for the whole machine, which holds the work and everything else the machine powered over the window, so it can read high for the work but never low.

Not yet a hardware reading

  • Apple Silicon host-wide CPU energy on macOS 27.0.1, for an unprivileged process — the IOReport CPU rails are frozen (above), so the load-avg estimator meters work outside this process. Built with smc, the whole machine meters it instead, ModelBased (above): a figure for the machine, not for the CPU.
  • Apple Neural Engine energy, unprivileged: ANE0 is frozen and ANE work runs in aned; the H1xANELoadBalancer clients carry no time or energy.
  • x86 CPU energy on Windows — RAPL MSRs need a kernel driver (e.g. WinRing0) on Windows; the NVML GPU path is the Windows HwShunt route. (CPU-side is an Estimator there for now.)
  • Jetson INA3221 (ina3221) and generic Linux hwmon (hwmon) backends exist behind their features but await the corresponding hardware to verify.