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
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]>
198 lines
13 KiB
Markdown
198 lines
13 KiB
Markdown
# 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.
|