jouleclaw-energy: a live per-process CPU energy counter for in-process work #123

Merged
dcharlot merged 2 commits from jouleclaw-energy/process-energy into main 2026-10-01 06:21:51 -04:00

2026-09-30

Transaction Science
jouleclaw-harness-econ: an in-process decode is metered by the process counter, and only that work
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m10s
standards workspaces build + test / aac-rs (pull_request) Successful in 39s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 44s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
standards workspaces build + test / map-rs (pull_request) Successful in 31s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m29s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m1s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m47s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m9s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m35s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m17s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m49s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m29s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m44s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m47s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m22s
`detect_in_process_meter` is for work that runs on this process's own threads.
Each caller that meters model work was checked against that, and adopted it
only where it holds.

- jouleclaw-harness-econ: `local_model_econ`, `swebench_local_coder` and
  `swebench_local_suite` meter a decode that runs in this process, on the
  loader's CPU kernels and rayon pool; the examples build the loader with
  `rayon` only, without its GPU `fabric`, and the metered window is the decode
  alone (the `python3` judge, a subprocess, runs outside it). They now use the
  in-process meter, so on macOS 27.0.1, where the host meter is the load-average
  estimator, a dispatch is a live hardware reading of its own CPU energy.
  `window_joules` already refuses a window the counter did not advance across.
  `meter_probe` reports both meters. Run here on a host under load average ~50:
  the host meter was the estimator (115.6 J over 2.89 s, Estimator, SocTotal),
  the in-process meter process-cpu (2.397 J over 2.84 s, HwShunt, ProcessCpu)
  for the same one-thread spin.
- jouledesk-github stays on `detect_meter`: its model runs in a
  `llama-completion` subprocess, which a per-process counter would bill
  nothing. Its docs say so, and a test pins the meter to the host meter.
- l3-jouleclaw's `run` binary stays on the host counter, and says why: `llama`
  is a subprocess, and the in-process `jouleclaw-runtime` engine boots the Apple
  backend's GPU kernels (Metal and MPS matmuls, the quantized GEMVs that serve
  decode), whose energy is the GPU's.
- jouleclaw-loader-gguf is unchanged: its metered paths (`tune_shape_live`,
  `fabric_calibrate`, `mpp_energy`) compare or calibrate GPU against CPU
  members, so they need the whole SoC, not one process's CPU.

Ran: `cargo test -p jouleclaw-harness-econ`, `cargo check` of its examples,
`cargo test -p jouledesk-github` (13 passed), and `cargo test` in
l3-jouleclaw. No model was loaded; the examples' model runs were not repeated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:21:00 -04:00
Transaction Science
jouleclaw-energy: a live counter of this process's own CPU energy, kept out of detect_meter
On macOS 27.0.1 an unprivileged process gets no live CPU energy from IOReport:
its CPU rails are frozen on an M5 Max and an M3 Ultra, so `detect_meter` falls
to the load-average estimator there. The kernel does keep one live CPU energy
figure an unprivileged process can read: `proc_pid_rusage(getpid(),
RUSAGE_INFO_V6)` returns `ri_energy_nj`, the cumulative energy it attributes to
this process's threads. Public libproc API, no privilege. Measured here (M5 Max,
macOS 27.0.1, 2026-09-30, host heavily loaded): it changes about every 0.6 ms
(86 changes in 50 ms of polling); 200 ms of one thread's CPU time read 0.448 J;
a 200 ms sleep read 137 µJ.

The trap is coverage. It reads this process's threads and nothing else: not
other processes, not this process's children, not the GPU, ANE or DRAM, this
process's own GPU work included. A figure from it is a hardware reading of work
that ran on this process's threads, and would bill zero for a model served by a
subprocess. JouleClaw SPEC §1.3: a figure must not claim coverage of a domain
its counter does not read.

- `process::ProcessCpuCounter` (macOS, no feature) reads it over a new domain,
  `EnergyDomain::ProcessCpu`, documented to exactly that scope, graded HwShunt
  like RAPL and NVML, and only while live: the shared sustained-advance rule,
  with the probing thread burning 25 ms of its own CPU between samples, probed
  once per process and cached (`process_cpu_liveness`). Whether AMX work a
  thread drives is in the kernel's figure is not verified, and the docs say so.
- A window over which it did not advance by a whole µJ is not a reading:
  `ProcessCpuCounter::measure` returns the new `EnergyError::NotAdvanced`,
  never a zero; a failed read or a counter going backwards is an error too.
- `detect_meter` does not include it, and says why. `detect_in_process_meter`
  returns it when live, else exactly `detect_meter`; its docs limit it to work
  on this process's own threads. `CompositeMeter::names` lists what a
  composite holds, so a test can say what is not in it.
- mesh-meter decides the new domain, as its exhaustive match requires: a
  per-process figure does not cover a mesh/1 request, so through `HwMeter` it
  would be an estimate. `HwMeter::detect` uses `detect_meter`, so it never
  meets this counter unless bridged by hand.

Tests. The hardware checks run in `tests/process_cpu.rs`, one test in its own
process, because the counter reads every thread of the process and concurrent
tests would be in the figure: live, probed once across four threads; 200 ms of
one thread's CPU reads 0.2 to 20 J, HwShunt over ProcessCpu; a 200 ms sleep
reads under 0.1 J or is NotAdvanced; `detect_meter` holds apple-ioreport,
apple-silicon and estimation and not process-cpu, while the in-process meter
selects it; a busy `/bin/sh` child that burned 0.744 J (its own counter, read
independently at the SDK's offset) put 264 µJ in this process's window, which
spans its kill and reap. In the lib: the struct matches the SDK layout (464
bytes, energy at 336, checked with the C compiler); a counter given Frozen or
Unavailable reads and measures nothing; the production probe over a held
reader is Frozen after the budget and over a failing one Unavailable; the
window rule; and a live one-sided burn check.

Ran: `cargo test -p jouleclaw-energy` with default features and with
`ioreport,rapl,nvml`; mesh-meter with `hw-meter`, `hw-meter-apple`,
`hw-meter-nvml`; clippy clean on the new code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:21:00 -04:00