Heterogeneous compute is the premise, so the taxonomy had to stop being three
Apple rails. `Fabric` now covers `Cpu`, `Gpu`, `Npu`, `Fpga`, `Dsp`, `Dram` and
`Package` — `Ane` is gone, since Apple's Neural Engine, a Qualcomm or Intel
NPU, a TPU, Ascend, Gaudi and Cambricon are one class of silicon and the vendor
belongs in the source, not the fabric.
`FabricSource` names the counter: `ProcRusage`, `IoReport`, `Powermetrics`,
`Rapl`, `Nvml`, `RocmSmi`, `LevelZero`, `TegraIna`, `QualcommQpt`, `AscendSmi`,
`HabanaSmi`, `CambriconCnmon`, `ExternalMeter`. A receipt can now say *which*
counter produced a figure: two readings graded `HwShunt` from different sources
are not interchangeable, because they have different domains, different update
periods and different attribution.
`survey()` returns every `(fabric, source)` pair with this host's status, and
the **five negative statuses are the substance**:
- `Frozen` — silicon and counter both present, counter not advancing.
- `Absent` — no such silicon here.
- `NeedsPrivilege` — present, needs root we do not have.
- `NoCounterExists` — present, and exposes no energy counter at all, so any
figure is modelled by anyone. A property of the hardware.
- `NotImplemented` — a counter exists and we have no reader. Our gap, named.
Blurring those is what makes an energy survey worthless, and one of them is
load-bearing outside this crate: a disclosure regime that excuses a provider
who "lacks critical information from the compute or hardware provider" is
relying on `NoCounterExists`, and this distinguishes it from the three statuses
that are nobody's hardware's fault. Presence is probed (`/sys/class/kfd`,
`/dev/accel/accel0`, `/dev/davinci0`, `/dev/hl0`, `/dev/cambricon_dev0`,
`/sys/bus/i2c/drivers/ina3221`, …) so an unread accelerator reports
`NotImplemented` rather than the false claim `Absent`. Apple's rails are
probed live — two reads with a CPU burn between them — because which rails a
macOS version exposes to an unprivileged reader is a fact to observe on the
host, not to assume.
**And the refusal is now real.** `FabricStatus::may_grade_span` enforces what
`min_window_ns` never did: a span shorter than the counter's update period is
not a measurement of that span, and a counter whose period is **undeclared**
can never grade a span at any length, because an unknown error direction at a
ceiling must be read as possibly-under-reporting.
That rule immediately indicted our own best figure, which is how it earned its
place: the per-process CPU counter had an undeclared period, so the only
process-attributed reading on Apple silicon could not be graded. Rather than
weaken the rule, `tools/counter-resolution/` measures the counter. Burning this
thread's CPU and sampling 60 times per interval, the fraction of intervals in
which `ri_energy_nj` advanced: **0% at 10 µs and 50 µs, 2% at 100 µs, 5% at
500 µs, 35% at 1 ms, 63% at 2 ms, 87% at 5 ms, 100% at 10 ms**. The crate's
liveness criterion is 80%, so 5 ms is where it first clears — and **10 ms is
declared**, because over-declaring refuses work that would have fit while
under-declaring admits work the budget cannot cover.
Below 1 ms it does not lag, it **batches**: on the few intervals where it moved
at all the median step was ~20 mJ, two orders above the 1 ms step. A sampler
reading it every 500 µs would see nothing most of the time and a 20 mJ jump
occasionally, and would report both as measurements — the same pathology the
crate already refuses on IOReport's CPU rails, and the same shape as the
published 95.4% under-read at 0.5 ms sampling.
With the period declared, this morning's figure stands for the reason it was
taken that way: 2.606 s ≫ 10 ms, which is why it is the mean of 200 decisions
over one long span and not 200 counter reads.
98 tests pass, 0 clippy findings. Six new invariants: each pair named once, a
source with no backend is never `Live`, an undeclared period grades nothing at
any span, source names are stable and distinct, only `ProcRusage` may claim
per-process attribution, and the declared period refuses exactly the intervals
the measurement showed it cannot resolve.
Co-Authored-By: Claude Opus 5 <[email protected]>
2.3 KiB
What a counter can actually resolve
Two harnesses behind the numbers jcp-energy declares. Both are the evidence
for a constant in the crate; neither is a test of the crate.
counter_resolution.py — the per-process CPU counter's update period
ri_energy_nj is the only source in the survey that attributes energy to a
principal rather than to silicon, so whether it may grade a span decides
whether Apple silicon has any process-attributed reading at all.
Burns this thread's CPU for a fixed interval, samples the counter 60 times per interval, and reports the fraction of intervals in which it advanced — the crate's own liveness criterion is 80%.
Measured on an M5 Max, macOS 27:
| interval | advanced | median step | clears 80% |
|---|---|---|---|
| 10 µs | 0% | — | no |
| 50 µs | 0% | — | no |
| 100 µs | 2% | 20.4 mJ | no |
| 500 µs | 5% | 21.1 mJ | no |
| 1 ms | 35% | 2.8 mJ | no |
| 2 ms | 63% | 4.9 mJ | no |
| 5 ms | 87% | 9.6 mJ | yes |
| 10 ms | 100% | 19.0 mJ | yes |
| 50 ms | 100% | 92.9 mJ | yes |
The crate declares 10 ms, not the 5 ms where it first clears the criterion: over-declaring a period refuses spans that would have been fine, while under-declaring grades spans that were not, and at a ceiling only the second error is dangerous.
Note the shape below 1 ms. The counter does not lag smoothly — on the few intervals where it advanced at all, the median step was ~20 mJ, two orders above the 1 ms step. It accrues in lumps, which is the same batched-refresh pathology the crate already refuses on IOReport's CPU rails. A sampler that read it every 500 µs would see nothing most of the time and a 20 mJ jump occasionally, and would report both as measurements.
This is also why a per-operation figure for an 11.85 ms decision is taken as the mean of 200 decisions over a 2.6 s span rather than as 200 counter reads.
ioreport_rails.py — which Apple rails exist and advance
Enumerates the IOReport Energy Model group unprivileged (364 rails on an
M5 Max) and prints each channel's name, unit and delta. What it establishes:
GPU Energy is live in nJ, every CPU Energy / MCPU* / PACC* /
*_SRAM rail is frozen for an unprivileged reader, and ANE0 is the only ANE
channel — no ANE Energy aggregate, no sub-rails. Units differ within one
sample, so a summer that assumes one unit is wrong.