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

198 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Measured energy — cross-platform verification
JouleClaw energy readings carry an honest [`Provenance`](src/lib.rs): `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`](examples/energy_probe.rs) 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:
```sh
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`](src/apple_ioreport.rs)) 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`](src/nvml.rs)) — the same code
reports `HwShunt` on Windows and Linux; it loads the driver's NVML library by
name on each OS.
- **RAPL** ([`rapl`](src/rapl.rs)) 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`](src/process.rs)) | `proc_pid_rusage` `ri_energy_nj` | this process's CPU | **HwShunt** while live |
| `ProcessGpuCounter` ([`process_gpu`](src/process_gpu.rs), `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`](src/span.rs) 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`](src/apple_smc.rs) (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.