# 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.