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]>
80 lines
4 KiB
Python
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}")
|