open-standards/jcp/tools/counter-resolution/gpu_rail_period.py
Transaction Science 5208dc5936
All checks were successful
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m57s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m29s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m13s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m43s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 8m28s
standards workspaces build + test / aac-rs (pull_request) Successful in 3m42s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 3m18s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m20s
standards workspaces build + test / map-rs (pull_request) Successful in 1m56s
standards workspaces build + test / mesh-rs (pull_request) Successful in 1m6s
standards workspaces build + test / openpay (pull_request) Successful in 7m12s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m41s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m16s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m18s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 7m16s
standards workspaces build + test / sense-rs (pull_request) Successful in 4m7s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 5m30s
mesh: withdraw the GPU per-decision figure — another process held 99.5% of the GPU
I reported 0.104 J per three-question decision as GPU 0.0966 J plus process CPU
0.0075 J, and put it in SPEC §3.6's worked example. The GPU half does not hold.

`gpu_share.py` measures the thing I had assumed away: per-process GPU time from
`accumulatedGPUTime` on each IOUserClient in the IOAccelerator subtree, as a
**delta over the span** rather than the cumulative totals (which are useless —
WindowServer has days of accrued time). Over a 4.06 s span with an MLX matmul
loop running, **com.apple.Safari held 2.470 s of 2.482 s — 99.5%** — while this
process registered 0.000 s. The host was at load average 132 on 18 CPUs,
running another session's VVC/VTM encodes, which is this machine's normal state
rather than an unlucky moment.

A rail delta over that span is a reading of Safari. The rule already shipped
elsewhere in the fleet is the right one: attribute a rail reading to this
process only when it holds ≥99% of the GPU time advancing in the span, report
exactly zero when it holds none, and otherwise **issue no GPU figure**, marking
the span CPU-only and partial. By that rule this measurement issues none.

The 0.08% idle-bracket correction I cited as evidence of cleanliness proves
nothing, and I should not have read it that way: a bracket subtracts *steady*
background, and cannot subtract a contending process whose work overlaps the
span but not the brackets.

What stands, and why it is the more interesting half: the **CPU** figures are
untouched — Laya 0.486 J and GLiNER2.5-Decide 0.555 J per three-question
decision — because the kernel's per-process counter attributes to a principal,
so neither load average 132 nor Safari's GPU work enters them. That is exactly
what `Attribution::Process` is for, and it took a contaminated GPU figure to
demonstrate it. SPEC §3.6's worked example now uses the CPU figure (486 000 µJ,
162 mJ per answered question) and says why it is not the GPU one. Latency
figures are unaffected.

Also corrects the direction of my earlier framing. I presented the GPU path as
"4.7× cheaper, and the price is that it cannot be bound to a principal". The
truth is stronger: on a shared host it cannot be bound to a principal *and*
the number itself is not the workload's. A sound GPU figure needs a host with
no display server and no other GPU client — on this fleet, a headless Linux box
with NVML, not a Mac.

`gpu_rail_period.py` is also added, the harness behind 92449670's correction of
the rail's declared period.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 07:20:58 -04:00

48 lines
2 KiB
Python

"""How long does the GPU Energy rail hold its value?
I declared 10 ms as its update period. PR #146 reports it "republishes in
steps and can hold 300-600 ms under load". Declaring too short a period is the
unsafe direction: it lets a span be graded that the counter never covered.
This measures the distribution of gaps between actual changes.
"""
import time, statistics, threading, sys
exec(open('rails.py').read().split('import time, subprocess, sys')[0])
def gpu_abs():
"""Absolute cumulative GPU nJ: delta against a fixed first sample."""
return None
def gaps(duration_s, label, load=None):
stop = threading.Event()
t = None
if load:
t = threading.Thread(target=load, args=(stop,), daemon=True); t.start()
time.sleep(0.3) # let the load spin up
prev_s = sample(); last_change = time.perf_counter(); g = []; steps = []
t_end = time.perf_counter() + duration_s
while time.perf_counter() < t_end:
time.sleep(0.002) # 2 ms sampling: finer than any claim
cur_s = sample()
d = IOR.IOReportCreateSamplesDelta(prev_s, cur_s, None)
tot = sum(v for _g, _sg, n, u, v in rails(d) if n == "GPU Energy")
if tot > 0:
now = time.perf_counter()
g.append((now - last_change) * 1e3); steps.append(tot / 1e6)
last_change = now
prev_s = cur_s # only advance the baseline on a change
if load:
stop.set(); t.join(timeout=2)
if not g:
print(f"{label:<16} no change in {duration_s}s"); return
g.sort()
print(f"{label:<16} n={len(g):<4} gap ms: p50={statistics.median(g):7.1f} "
f"p90={g[int(.9*len(g))-1]:7.1f} max={g[-1]:7.1f} median step={statistics.median(steps):8.2f} mJ")
def mlx_load(stop):
import mlx.core as mx
a = mx.random.normal((2048, 2048)); b = mx.random.normal((2048, 2048))
while not stop.is_set():
c = a @ b; mx.eval(c)
gaps(6.0, "idle")
gaps(6.0, "mlx matmul", mlx_load)