eoc-meter: never build the IOReport counter on a CPU rail that does not keep moving #116
Loading…
Reference in a new issue
No description provided.
Delete branch "eoc-meter/cpu-rail-liveness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
eoc-meter's IOReport counter was built whenever a subscription opened. eoc-cascade then tagged any positive overhead as
JouleSource::Measured.What was wrong: on main
eoc query "totally novel question"on the M5 Max printedcost : 50136686 µJ (measured)in 4 of 4 runs. That is the 50 J neural-stage estimate plus a GPU-only IOReport delta, sealed into the receipt as measured.eoc meterprintedcounter: ioreportwithidle : 307124 µJon the M5 Max, and10279 µJon the M3 Ultra. Both are GPU-rail energy.energy must advance: before=0 after=0).What this does
MacosIoReportCounter::try_detect()returnsErr(NoCounter)unless the family rule says Live.ioreport_status()gives the reason.attach_cascade_costkeeps each stage's own provenance.cost : 50000000 µJ (estimated).eoc meterprints the IOReport status and never prints a failed read as0 µJ over 1.0 s.counts_onceadmitted per-cluster and power-manager sub-rails (PACC_*,MCPM*,PCPM,*_CPM, and their_SRAMrails) beside theCPU Energyaggregate they sum into. The hierarchy was confirmed from absolute values on both chips (CPU Energy=MCPU0+MCPU1+PCPU, to within 1 mJ). On a live host CPU energy would have been summed twice. No reading so far was affected, because no host is live.Verification of this PR (head
65ff45ba)Later rounds on this branch
f84f0b2f: a cache hit is an estimated zero, never a measured one. A total is Measured only if every microjoule was read.0db9b8beand6c37e299: eoc-carbon carries provenance through EvalReport, CarbonAccount and SCI. An SCI score at PUE > 1 is Estimated, because its overhead is modeled. A run with no correct answer has no score; it used to produce an 18.4 TJ energy term.6a8d7c68: an unmetered stream has no figure, never 0 µJ.15cf07c2and1535b8eb: eoc-local never turns a failed first read into a since-boot total, and inference that ran, including a llamacpp error, is never 0 µJ.2c353d68,b2e8464cand26165a77:package-Nzone, never psys or dram, each wrapped at its own range.52d9d1e2,371fbe5e,6cf8b60eand1352aeea(eoc-agent):Why: what IOReport actually does on macOS 27.0.1
Measured by reading channels directly on the M5 Max (dev) and the M3 Ultra (10.10.10.2), both on macOS 27.0.1:
CPU EnergyandDIE_<n>_CPU Energydo not move under load for seconds to minutes. On the M3 Ultra both dies held for at least 22 minutes, then jumped about 1.3 kJ each.+61,764,485 mJ, then+3,435 mJ64 ms later.A meter that sums rails therefore reports CPU work as GPU-only energy (M5 Max) or 0 (M3 Ultra), labelled as a reading. A liveness check that accepts one advance, or two, gives a false Live whenever its window holds a refresh. After that, each later ~1.3 kJ lump is booked whole to whichever span straddles it.
The rule: identical in all five backends (#110, #114, #115, #116, #117)
CPU Energy, or eachDIE_<n>_CPU Energy), keyed by name and ordinal:namereadspackage-N, on every socket, and each must be live. It never sumspsys, which already includes the packages, nordramor the sub-zones.max_energy_range_uj. A wrap whose range is unknown gives no figure; it never becomes a ~1.8×10¹⁹ µJ reading.Livedeclines the backend, so the crate's existing honest fallback runs. The reason is public:IoReportStatus,ioreport_status(),rapl_status(),nvml_status().admits(status)(Live only).How it was verified
Six rounds of implementation, each checked by independent adversarial verification.
Pure tests on scripted samples and a scripted clock. They cover:
Fault hooks inside the real IOReport opener and sampler. The tests drive the real production status through each script: opener fails, a single lump, a two-step lump, constant values, and a sustained advance. So a bypass written into the glue fails a test.
Hardware tests against an independent oracle. The oracle has its own subscription, reads absolute values, burns on a helper thread, and counts a rail as moving only on a sustained change. The tests check:
Mutation-tested. Each of the following was applied as a temporary edit and failed a test:
admits()that accepts CpuRailAbsent.The counts per crate are below.
Robustness. Each crate passes under artificial CPU load and while another process samples every IOReport channel in a tight loop. The latter is what made an earlier wall-clock pin flaky, so the job now runs on the shared CI host without that flake. Each also passes on the M3 Ultra.
CI: these tests are behind
cfg(target_os = "macos"), and this workspace runs on a linux runner. They reach CI through #102, which merges last. #102 runs every backend's lib and integration tests on macOS, and fails any step whose frozen-rail test did not printioreport liveness:.Merge order: #110, #114, #115, #116 and #117 are independent of each other. #102 goes after all of them.
Open questions, now decided and implemented (per "fix it all")
measured_ujthat carry estimates. Their grade now sits beside them in plaintext: jcp-gateway's_metagetsenergy_provenance, plusenergy_coveragewhen partial. The keys stay, for compatibility.Meterdefaults to Estimator, and the conformance fixture keeps HwShunt explicitly, so the vectors are byte-identical.Authorizer::chargecarries the figure's provenance.ri_energy_nj: unprivileged, live on both Macs, and attributed to the work. It is wired in as stacked PRs #120–#124, only where the work runs in-process. (Privilegedpowermetricsis live too, but machine-wide and root-only, so it is not used.)Still open
joule-ui-servegives it honest grades, and the per-process probe in #121 gives it real readings.🤖 Generated with Claude Code
The jouleclaw-energy verifier found the defect on 2026-09-30, on the M5 Max (macOS 27.0.1). The liveness rule said Live when the same CPU rail advanced in two separate sample intervals (LIVE_RAIL_ADVANCES = 2). That rule assumed a batch refresh lands as one step. It does not always. A watcher sampling the Energy Model group alone every ~16 ms saw `CPU Energy` step 2,643,843,378 -> 2,705,607,863 (+61,764,485), then 2,705,607,863 -> 2,705,611,298 (+3,435) 64 ms later. MCPU0, MCPU1 and PCPU each stepped twice too. The probe samples every ~31 ms, so a probe window that holds such a refresh sees two advancing intervals and says Live, and the status is cached for the process. That happened: two real probes said Live while the oracle saw no sustained movement, and both coincided with a refresh. Refreshes come ~30 minutes apart, so this is rare, but it is exactly the failure the rule exists to prevent. On this M5 Max `CPU Energy` has held at 2,705,611,298, that refresh's second step, in every run for this commit that printed it. The rule now asks for a sustained advance. A rail is live only if the same rail (same key: name + ordinal) meets three bars after some interval: - it advanced in at least MIN_LIVE_ADVANCES = 5 separate intervals; - those were at least 80% of every interval observed so far (advancing * 5 >= intervals * 4); - the samples observed so far span at least MIN_LIVE_WINDOW = 300 ms of wall time, measured from the first usable sample. It is evaluated after each interval, and Live is declared the first time all three hold. A burst of steps fails the window while it is young, and fails the share once it is not. The rule needs each sample's wall time, so RailWatch::observe now takes it: watch() reads its injected clock as each sample returns, and tests script that clock. The probe keeps sampling until the rail is Live, or until the budget (1 s) has elapsed and at least 5 intervals were observed, and never past 5 budgets. Otherwise the verdict is Frozen, Absent or Unavailable, as before. Nothing else changed: per-rail keys, consecutive-sample pairing, a decrease or a first appearance is not an advance, one advance per rail per interval, the group-only subscription, the thread-CPU tick, the pure status mapping, the OnceLock cache, the scripted leaves, try_detect_given / admits_counter, counts_once, and the "ioreport liveness: status=" line. The cost: a live rail is declared after 300 ms at the least, not ~60 ms. A frozen verdict still takes the ~1 s budget. The test oracle had the same weakness. It said CpuRailMoved(true) when one rail changed at two reads, so a refresh that coincided with a false Live vouched for it. Now it is CpuRailMoved(true) only if one rail changed at least 5 times, at no fewer than 80% of the read pairs it compared. It keeps reading past its window until it has compared 5 pairs (6 reads), so a live rail could meet the bar, and it stops at 5 windows. Too few pairs is still a test failure, never a verdict. The per-read tally is now a pure OracleTally, so the bar runs on scripted reads too. The docs that described the old two-step rule are updated: the constants, CpuRail, RailWatch, the macos_ioreport module comment, IoReportStatus::{CpuRailFrozen, Live}, CPU_RAIL_LIVENESS_BUDGET and ioreport_status. Tests. The rule tests use a scripted 31 ms interval unless noted. (*) marks the ones that must fail under the old ">= 2 advances" rule. That was checked by temporarily restoring the old rule: 13 tests failed, every (*) test among them. The F9 test failed on its two-step script: "a refresh in two steps (5 samples) must be frozen", left: Live. - (*) h_the_two_step_refresh_is_frozen: the observed +61,764,485 / +3,435 refresh is Frozen at every position tried. The steps land in intervals (3,4), (1,2), (1,3), (3,5), (20,21) and (31,33), beside a moving GPU rail and sub-rails that step with it. - (*) i_a_refresh_in_three_to_five_steps_at_the_start_is_frozen: 3, 4 or 5 steps within 150 ms at the start (30 ms a sample) are Frozen. At five steps every interval so far has advanced; only the window stops it. - (*) j_the_m3_ultra_two_steps_on_each_die_are_frozen: DIE_0 and DIE_1 each step twice within 64 ms, together or one interval apart, at the start and mid-probe, amid same-named per-die channels. Frozen. - d_a_rail_advancing_every_interval_is_live_at_five_advances_and_300_ms: not Live at 279 ms; Live at the 10th interval (310 ms), and the probe stops there. At 60 ms a sample it is Live at the 5th interval (exactly 300 ms); at 59 ms, only at the 6th. - k_a_live_rail_missing_one_interval_in_five_is_live_two_is_not_yet: missing 1 interval in every 5 is Live at 8 of 10. Missing 2 of the first 5 (100 ms a sample) is not Live at 3 of 5 or at 7 of 9; it is Live at 8 of 10. Missing 2 in every 5 is Frozen. - l_a_live_rail_on_a_loaded_host_is_live_after_five_intervals: at 100 ms intervals, Live after exactly 5 intervals (500 ms). - f_the_budget_stops_the_probe_only_once_five_intervals_are_seen: with fewer than 5 intervals the probe keeps sampling past the budget, and the 5-budget cap stops it (at 2 s ticks: 3 intervals, all advancing, Frozen). - sustained_needs_five_advances_80_percent_and_300_ms: each bar at its edge. - probe_with_the_two_step_refresh_is_frozen and probe_with_a_sustained_advance_is_live: the same rule through probe_with's fakes. The sustained case is Live at 12 intervals, 300 ms. - F9, the real host_status() on scripted IOReport leaves. The old "two advances -> Live at the third sample" script is now a sustained advance: Live after 6..=14 samples and >= 300 ms of real wall time. Also added: (*) the two-step refresh through host_status() gives CpuRailFrozen. - the_oracle_does_not_count_a_refresh_as_movement: the two-step refresh, a single step and a five-step burst are no movement; 5 of 5, 16 of 20 and every read are movement; 15 of 20 and 4 of 4 are not. - Minimums follow the rule. A frozen verdict holds at least 5 ticks, and the budget comments say five intervals. Mutations. Each was a temporary edit, then `git checkout`; the tree was clean after every one. All four were caught (tests that failed): - MIN_LIVE_ADVANCES = 2: f_..., l_..., sustained_... - 80% share check removed: a_decrease_is_not_an_advance (a rail rising and falling in turn), i_..., k_..., sustained_... - 300 ms window check removed: c_..., d_..., i_..., k_..., probe_with_a_sustained_advance_is_live, sustained_..., host_status_is_the_rule_on_the_production_composition - oracle back to ">= 2 changes": the_oracle_does_not_count_a_refresh_as_movement Evidence. - M5 Max, unloaded: eoc-meter has 45 lib tests (37 before) and the once-per-process test, all passing. `ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 7 reads over 3.062004833s with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [5], of 6 compared pairs)` - M5 Max, 3 runs of all eoc-meter tests beside a helper process sampling every IOReport channel in a tight loop: 3/3 ok, 45 + 1 passed each. The oracle read past its 3 s window to reach 5 compared pairs: `ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 6 reads over 3.664727125s with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [4], of 5 compared pairs)`, then 11 reads over 3.54 s, then 9 reads over 3.57 s. The fresh probe said Frozen every time (12-14 intervals over ~1 s). - M3 Ultra (10.10.10.2, macOS 27.0.1), 1 run of the lib and once-per-process test binaries: 45 + 1 passed. `ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 18 reads over 3.101584s with a helper thread burning CPU; changes per CPU rail [0, 0], per GPU rail [17], of 17 compared pairs)` - The whole eoc workspace: 621 passed, 0 failed, 3 ignored. The ignored three are the pre-existing ignored doctests. - clippy --no-deps on eoc-meter, all targets: no new warnings. The only two are the pre-existing ones (a duplicated #[link] attribute, and a b"...\0" literal). The aarch64-linux cross clippy could not run here: blake3's C build finds no Linux sysroot. Co-Authored-By: Claude Opus 5.5 <[email protected]>WIP: eoc-meter: never build the IOReport counter on a CPU rail that does not keep movingto eoc-meter: never build the IOReport counter on a CPU rail that does not keep moving