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]>
13 KiB
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 gradedHwShunt, 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 Energyfrozen whileGPU Energyadvances) 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 taggedEstimator. Where the rail is live, the receipt is taggedHwShunt. 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 reportsHwShunton Windows and Linux; it loads the driver's NVML library by name on each OS. - RAPL (
rapl) needs thepowercapsysfs interface, which is exposed on bare-metal Linux (not most virtualised guests). On modern kernelsintel-rapl:*/energy_ujis 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 andioreg'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 withQ8_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.
- sole GPU user: 0.128 J/token = CPU 0.069 + GPU 0.060,
- 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.
PSTRchanges 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
PSTRequals the DC input,PDTR. On the MacBook Pro on AC with the battery not charging,PSTRread 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
ModelBasedgrade 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:
ANE0is frozen and ANE work runs inaned; theH1xANELoadBalancerclients 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
HwShuntroute. (CPU-side is anEstimatorthere for now.) - Jetson INA3221 (
ina3221) and generic Linuxhwmon(hwmon) backends exist behind their features but await the corresponding hardware to verify.