eoc-meter: a live per-process CPU energy counter for in-process cascades #124

Merged
dcharlot merged 4 commits from eoc-meter/process-energy into main 2026-10-01 06:22:08 -04:00

2026-09-30

Transaction Science
eoc-meter: the one-thread burn test's floor matches what 200 ms of CPU can cost
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m41s
standards workspaces build + test / aac-rs (pull_request) Successful in 34s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 34s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
standards workspaces build + test / map-rs (pull_request) Successful in 32s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m53s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m16s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m52s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m42s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m12s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m16s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m17s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m48s
The process-energy burn test asked for user-interactive QoS so its burn
would run on the fast cores, and it asserted at least 0.2 J. The QoS call
returns 0 but changes nothing: the kernel caps a process with no app role,
such as a test binary, at default QoS. rusage books all of the burn as
default QoS (measured in jcp-energy's matching test, 22e5a6ad).

The burn already counts 200 ms of the thread's own CPU time, not wall time,
but the energy those 200 ms cost still depends on which core class ran
them, and at what clock. On an M5 Max at default QoS it read 0.18-1.24 J,
so a 0.2 J floor sits at the bottom of the real range.

This drops the ineffective QoS call and its declarations, records why in
the test's doc, and lowers the floor to 0.1 J: half the lowest reading,
and the sleeping test's ceiling. A counter that reads wall time at a fixed
power still cannot pass both tests, and a counter reading 0 still fails.

On this M5 Max, 4/4 pass: the burn read 0.504 J, and 500 ms asleep 21 µJ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:49:58 -04:00
Transaction Science
eoc: the in-process burn test's backend is built only on macOS, where its one test runs
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
`BurningUnestimatedBackend` exists for one test,
`in_process_work_is_measured_by_the_process_counter`, which is
`#[cfg(target_os = "macos")]`: it asserts this process's own counter is live,
and `process_energy_status()` is always `Unavailable` off macOS, where
`detect_in_process()` is `detect()`. The backend itself had no cfg, so on every
other target it was never constructed, and `cargo clippy --all-targets` for
x86_64-unknown-linux-gnu warned "struct `BurningUnestimatedBackend` is never
constructed". On macOS there was no warning, since the test builds it there.

No test off macOS was meant to use it. A backend that claims an estimated zero
for real work, on a host with no live counter, costs that zero under
eoc-cascade's rule (with no live span the stage's own figure stands); a test
there would pin a 0 for work that ran, not a measurement. The non-macOS path
of the CLI's cascade is already covered by
`work_on_an_unmetered_host_keeps_its_estimate`, with a backend that does
estimate. So the struct and its impl take the test's cfg instead of being
used somewhere they would assert the wrong thing.

clippy on `eoc` with `--all-targets`, for aarch64-apple-darwin and for
x86_64-unknown-linux-gnu: no warnings in the crate (the second had the one
above before this).
On an M5 Max (macOS 27.0.1) the test still builds it and passes: "eoc
in-process cost: 260170 µJ (measured)". The eoc workspace, rebased onto the
eoc-agent changes below it: 705 passed, 0 failed, 3 ignored; eoc-meter's
tests/process_energy.rs: 4 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:41:13 -04:00
Transaction Science
eoc: the CLI's cascade is metered by this process's own counter, because all of its stages run in-process
`detect_in_process()` is for callers whose metered work runs on the CPU in
this process, and only for them. Each place eoc picks a counter was checked
against that:

- The `eoc` CLI's query cascade: adopted. Every stage it builds runs on the
  CPU in this process: the LRU cache, the in-memory KV store, the in-memory
  graph, and the echo backend, which formats a string. None spawns a
  subprocess or offloads to the GPU, and they run on this process's tokio
  workers. `build_cascade_with` now meters with `detect_in_process()`, and
  its doc says a backend passed to it must keep to the same.
- `eoc meter` still reports `detect()`'s counter and its idle draw, and now
  also names the counter `eoc query` meters with.
- Not adopted anywhere else, because no other eoc code picks a counter:
  eoc-cascade and eoc-agent default to the stub and take the caller's;
  eoc-streaming and eoc-local's backends take the caller's. eoc-agent must not
  be given this one in any case: its tools include eoc-tools' shell, a
  subprocess.
- eoc-local's crate doc now says which backends this counter covers. All four
  run in this process, but `mlx` (GPU, Neural Engine), `mlc` (GPU),
  `llamacpp` (GPU by default, `n_gpu_layers` 999) and `onnx` with a CoreML,
  CUDA, DirectML or TensorRT provider offload work it does not see. It covers
  `onnx` with the CPU provider and `llamacpp` with `n_gpu_layers` 0.

What the CLI reports on this M5 Max:

  counter: powermetrics
  query  : metered by process-energy (its stages all run on the CPU in this process)
  eoc query "what is 2+2"           -> stage kv, cost 50 µJ (estimated)
  eoc query "tell me something new" -> stage neural, cost 50000000 µJ (estimated)

Both lookups finish inside one update of the counter, so neither span
advanced and each stage's own figure stands, as eoc-cascade requires.

Tests. `work_on_an_unmetered_host_keeps_its_estimate` now expects the CLI's
meter to be `process-energy` exactly when it is live (and IOReport's rule
otherwise); its ~100 ms burn with a 50 J estimate costs the estimate plus the
reading, tagged estimated: "meter=process-energy cost=50230593 µJ
(estimated)" in one run. A new test, `in_process_work_is_measured_by_the_process_counter`,
runs ~100 ms of CPU work through the CLI cascade from a backend that claims
nothing (an estimated zero): it reads as measured, between 1 mJ and 10 J. The
floor is low on purpose, since a wall-time burn on a loaded host or an
efficiency core can draw little; the size of a reading is pinned in
eoc-meter's hardware test. Here: "eoc in-process cost: 273344 µJ (measured)"
and "meter=process-energy cost=50270761 µJ (estimated)". The two burning tests
take one lock, since each would otherwise read the other's burn: run in
parallel they read 380637 µJ each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:39:01 -04:00
Transaction Science
eoc-meter: this process's own CPU energy is a live counter, and detect() never selects it
On macOS 27, `proc_pid_rusage(getpid(), RUSAGE_INFO_V6)` (libproc: public,
no privilege, no entitlement) returns `ri_energy_nj`, the cumulative CPU
energy the kernel attributes to this process's threads. Unlike IOReport's CPU
rails, it is live for an unprivileged reader: it advanced in 20 of 20
intervals here, under 25 ms of CPU each. It is the first live CPU energy
counter eoc-meter has on a Mac.

Its domain is the trap, and the design follows from it: this process's
threads' CPU energy, all of them, and nothing else. Not a child process (a
child's energy is its own, and is not added to the parent's when reaped), not
the GPU, the Neural Engine or DRAM. eoc's counter brackets stages that may run
in a subprocess (eoc-tools' shell tool, HumanEval's python) or on the GPU
(mlx, mlc, llama.cpp's default offload). A span over such work would read only
this process's share and be tagged measured: a figure for work the counter did
not see. So:

- `MacosProcessEnergyCounter` (`JouleCounter`, cumulative µJ =
  `ri_energy_nj` / 1000, name `process-energy`) is constructed only where it
  is live. Its doc states the domain exactly.
- `process_energy_status()` probes it once per process and caches the answer,
  by the same sustained-advance rule and `probe_counter` the RAPL and NVML
  rungs use, burning this thread's CPU between samples (that burn is this
  process's work, so a live counter advances in every interval). It is
  `Unavailable` off macOS.
- `detect()` does **not** select it, under any status of any rung. A caller
  whose metered work runs on the CPU in this process opts in with
  `detect_in_process()` (this counter where live, otherwise `detect()`) or
  `MacosProcessEnergyCounter::try_new()`.
- A span the counter did not advance over is still no measurement; the
  consumers enforce that (eoc-cascade's `live_span`, eoc-streaming, eoc-local).

The FFI is hand-rolled: `struct rusage_info_v6` field for field from the
macOS 27 SDK, with compile-time asserts that it is 464 bytes and
`ri_energy_nj` is at byte 336, as a C program compiled against the SDK prints.
It is the crate's third scoped `allow(unsafe_code)` module.

Tests. In the library, on scripted reads: the probe calls a counter that
advances every read live; a constant, one step, or a failing
`proc_pid_rusage` is declined. Only `Live` constructs the counter, which then
reads this pid. A pid that does not exist reads nothing. `detect_with` never
returns the process counter for any of the 36 combinations of NVML, RAPL and
IOReport status, and `detect()` never does here; `detect_in_process` picks it
exactly for `Live`, on macOS.

Against the hardware, in an integration test binary of its own (`tests/
process_energy.rs`), because the counter's domain is the whole process and
other tests in the library binary burn CPU on other threads; its tests take
one lock. Each figure is checked against an oracle the test reads itself:
`proc_pid_rusage` into a raw buffer at the SDK's offsets. No test can pass
without asserting.

- live: the status agrees with the oracle (live if it advanced in 80% of 20
  intervals), and on Apple Silicon both must say live;
  `detect_in_process()` picks it; `detect()` does not.
- 200 ms of one thread's CPU reads 0.2-20 J, inside the oracle's bracket.
  The burn is 200 ms of the thread's own CPU, by its CPU clock, at
  user-interactive QoS. A 200 ms wall-time spin failed on this M5 Max under a
  load average near 70 ("174844 µJ for 200 ms of one thread's CPU work": it
  got 90 ms of CPU). And the bound is for a performance core: 200 ms of CPU on
  an M3 Ultra efficiency core (background QoS) reads 0.013 J, against 0.72 J
  at default QoS and 1.10 J at user-interactive (a C probe, not this test);
- 500 ms asleep reads under 0.1 J;
- a child process (`yes` to /dev/null, run at least 300 ms and until its own
  counter reads 0.5 J) is not attributed: the parent's counter, read before
  the spawn and after the reap, moves under 0.1 J and under a fifth of the
  child's own, while the reaped child's CPU time shows it worked.

Measured, verbatim from the test logs:

M5 Max (macOS 27.0.1, load average ~50):
  process-energy oracle: 20 of 20 intervals advanced
  status=Live oracle_live=true detect_in_process()=process-energy detect()=powermetrics
  one-thread burn of 200.001792ms thread CPU in 205.349583ms wall read 600514 µJ = 0.601 J (3.00 J per CPU-second)
  500 ms asleep read 512 µJ = 0.0005 J
  child ran 315.771709ms, its own counter read Some(700069134) nJ, its CPU (reaped) 289.941208ms; the parent's counter moved 1708 µJ = 0.0017 J
  (three runs before it: burns of 0.483, 0.444, 0.408 J; asleep 4894, 2735, 47 µJ; the parent moved 14311, 1404, 1199 µJ while a child read 0.54-0.61 J)

M3 Ultra (macOS 27.0.1, test binaries built for apple-m3, copied, run, deleted):
  process-energy oracle: 20 of 20 intervals advanced
  status=Live oracle_live=true detect_in_process()=process-energy detect()=powermetrics
  one-thread burn of 200.003584ms thread CPU in 200.008542ms wall read 1329909 µJ = 1.330 J (6.65 J per CPU-second)
  500 ms asleep read 184 µJ = 0.0002 J
  child ran 356.917167ms, its own counter read Some(1725566789) nJ, its CPU (reaped) 356.596542ms; the parent's counter moved 187 µJ = 0.0002 J

So the counter is live on both chips. The first M3 Ultra run failed
`a_child_processs_work_is_not_attributed` with "the child did not work:
120.042625ms of CPU": the child was stopped as soon as its counter read 0.5 J,
which the M3 Ultra's `yes` reached in 120 ms (0.716 J), under the 200 ms CPU
floor the test then demanded. A flaw in the test, not the counter; the parent
had moved 843 µJ. The child now runs at least 300 ms, and the floor is 150 ms.
The library's 62 tests passed there too.

Falsified, each mutant restored byte-for-byte (sha256 checked), run against
the library and the hardware binary:
- the process counter as a `detect()` rung fails 3 tests (`detect_with(
  Unavailable, Unavailable, Unavailable)` picks it; `detect()` picks it);
- a constant reading (counter only) fails the burn test ("0 µJ for 200 ms of
  one thread's CPU work"); a constant at the source makes the probe decline,
  and fails 4;
- reading the parent's pid fails 2 (the burn is not the parent's); probing
  the parent's pid fails 4;
- the constructor ignoring its status fails 2.

clippy raises no new warning. `cargo check --tests` for
x86_64-unknown-linux-gnu, with and without `cuda`, is clean.

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