jouleclaw-energy: a live per-process CPU energy counter for in-process work #123
Merged
dcharlot
merged 2 commits from 2026-10-01 06:21:51 -04:00
jouleclaw-energy/process-energy into main 2026-09-30
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]>
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]>