open-standards/jcp/tools/counter-resolution/ioreport_rails.py
Transaction Science 421b7e9b71 jcp-energy: survey every accelerator, and refuse a span the counter cannot resolve
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]>
2026-10-05 07:20:58 -04:00

80 lines
4 KiB
Python

"""Enumerate IOReport Energy Model rails on Apple Silicon, unprivileged.
Decisive question: are the GPU and ANE energy rails readable without sudo on
macOS 27, and do they advance under a Metal workload?
"""
from ctypes import *
CF = CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
IOR = CDLL("/usr/lib/libIOReport.dylib")
CFStringRef = c_void_p; CFDictionaryRef = c_void_p; CFArrayRef = c_void_p
CF.CFStringCreateWithCString.restype = CFStringRef
CF.CFStringCreateWithCString.argtypes = [c_void_p, c_char_p, c_uint32]
CF.CFStringGetCStringPtr.restype = c_char_p
CF.CFStringGetCStringPtr.argtypes = [CFStringRef, c_uint32]
CF.CFStringGetCString.restype = c_bool
CF.CFStringGetCString.argtypes = [CFStringRef, c_char_p, c_long, c_uint32]
CF.CFDictionaryGetValue.restype = c_void_p
CF.CFDictionaryGetValue.argtypes = [CFDictionaryRef, c_void_p]
CF.CFArrayGetCount.restype = c_long
CF.CFArrayGetCount.argtypes = [CFArrayRef]
CF.CFArrayGetValueAtIndex.restype = c_void_p
CF.CFArrayGetValueAtIndex.argtypes = [CFArrayRef, c_long]
def cfstr(s): return CF.CFStringCreateWithCString(None, s.encode(), 0x08000100) # kCFStringEncodingUTF8
def pystr(ref):
if not ref: return None
p = CF.CFStringGetCStringPtr(ref, 0x08000100)
if p: return p.decode(errors="replace")
buf = create_string_buffer(512)
return buf.value.decode(errors="replace") if CF.CFStringGetCString(ref, buf, 512, 0x08000100) else None
IOR.IOReportCopyChannelsInGroup.restype = CFDictionaryRef
IOR.IOReportCopyChannelsInGroup.argtypes = [CFStringRef, CFStringRef, c_uint64, c_uint64, c_uint64]
IOR.IOReportCreateSubscription.restype = c_void_p
IOR.IOReportCreateSubscription.argtypes = [c_void_p, CFDictionaryRef, POINTER(CFDictionaryRef), c_uint64, c_void_p]
IOR.IOReportCreateSamples.restype = CFDictionaryRef
IOR.IOReportCreateSamples.argtypes = [c_void_p, CFDictionaryRef, c_void_p]
IOR.IOReportCreateSamplesDelta.restype = CFDictionaryRef
IOR.IOReportCreateSamplesDelta.argtypes = [CFDictionaryRef, CFDictionaryRef, c_void_p]
for fn, rt in (("IOReportChannelGetChannelName", CFStringRef), ("IOReportChannelGetGroup", CFStringRef),
("IOReportChannelGetSubGroup", CFStringRef), ("IOReportChannelGetUnitLabel", CFStringRef)):
getattr(IOR, fn).restype = rt; getattr(IOR, fn).argtypes = [CFDictionaryRef]
IOR.IOReportSimpleGetIntegerValue.restype = c_int64
IOR.IOReportSimpleGetIntegerValue.argtypes = [CFDictionaryRef, c_int32]
chans = IOR.IOReportCopyChannelsInGroup(cfstr("Energy Model"), None, 0, 0, 0)
if not chans: raise SystemExit("no Energy Model group")
subbed = CFDictionaryRef()
sub = IOR.IOReportCreateSubscription(None, chans, byref(subbed), 0, None)
if not sub: raise SystemExit("subscription refused (would mean privilege is required)")
def sample(): return IOR.IOReportCreateSamples(sub, subbed, None)
def rails(delta):
out = []
arr = CF.CFDictionaryGetValue(delta, cfstr("IOReportChannels"))
for i in range(CF.CFArrayGetCount(arr)):
ch = CF.CFArrayGetValueAtIndex(arr, i)
out.append((pystr(IOR.IOReportChannelGetGroup(ch)), pystr(IOR.IOReportChannelGetSubGroup(ch)),
pystr(IOR.IOReportChannelGetChannelName(ch)), pystr(IOR.IOReportChannelGetUnitLabel(ch)),
IOR.IOReportSimpleGetIntegerValue(ch, 0)))
return out
import time, subprocess, sys
s0 = sample()
# Load the GPU (and ANE if asked) while the rails are watched.
load = subprocess.Popen([sys.argv[1], sys.argv[2]]) if len(sys.argv) > 2 else None
time.sleep(2.0)
s1 = sample()
if load: load.wait()
d = IOR.IOReportCreateSamplesDelta(s0, s1, None)
rs = rails(d)
print(f"subscription: OK (unprivileged, uid={__import__('os').getuid()}) rails in Energy Model: {len(rs)}")
agg = {}
for g, sg, name, unit, val in rs:
key = (sg or "-", name or "-", unit or "-")
a = agg.setdefault(key, [0, 0]); a[0] += val; a[1] += 1
print(f"{'subgroup':<22}{'channel':<34}{'unit':<8}{'delta over 2s':>16}{'n':>5}")
for (sg, name, unit), (val, n) in sorted(agg.items(), key=lambda kv: -abs(kv[1][0]))[:28]:
print(f"{sg:<22}{name:<34}{unit:<8}{val:>16}{n:>5}")