open-standards/mesh/tools/joules-per-decision/two_fabrics.py
Transaction Science f78a796f3c mesh/tools: the attribution trade, measured with the fabric held constant
The cheapest fabric and the attributable one are not the same fabric, and this
measures the gap instead of asserting it.

On an M5 Max, the same three-question bounded decision:

  - Laya 421M on **CPU**: 0.486 J, 629 ms
  - GLiNER2.5-Decide 340M on **CPU**: 0.555 J, 480 ms
  - Laya 421M on **GPU**: 0.104 J, 11.9 ms

Holding the fabric constant, the two models are close — GLiNER2.5-Decide
spends 14% more per decision and answers 24% faster, while scoring 60.1% to
Laya's 46.6% on Fastino's 17-dataset suite. Worth recording because the Mesh
SPEC's §3.6 figures come from Laya, which is the **weakest** member of this
family on the only cross-model suite published for it; nothing here should
imply it is the best available.

Moving the same model to the GPU costs 4.7× fewer joules and is 53× faster, and
the figure stops being attributable: IOReport's `GPU Energy` rail is a real
reading of the whole GPU that cannot say whose work it was. So the cheap path
yields a number that cannot be bound to a principal and the expensive path
yields one that can — which is the concrete reason `Attribution` is a separate
axis from `Coverage` rather than a weaker grade of it.

The CPU figure is also *complete*, not just attributable: over the GLiNER run
the GPU rail, bracketed by idle baselines either side, moved by −0.0013 J per
decision. Negative — the idle estimate slightly exceeded the real draw — so GPU
involvement sits below the measurement's own noise and the process-attributed
CPU number is the whole task. That is the claim the GPU rows cannot make.

`two_fabrics.py` is the harness. It needs `gliner2`, `peft` and `torch`; the
model is Apache-2.0 and 340M, and it runs on CPU without a GPU at all, which
is what makes the attributable path available on any host rather than only on
one with a counter we can read per principal.

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

71 lines
3 KiB
Python

"""One bounded decision, two fabrics, two attributions.
CPU: the kernel's per-process counter — Coverage::Whole for CPU work,
Attribution::Process, declared period 10 ms.
GPU: IOReport's `GPU Energy` rail — a real reading of the whole GPU that
cannot tell one process from another, so Attribution::Machine, bracketed by
idle baselines.
"""
import ctypes, os, time, json, statistics, warnings
warnings.filterwarnings("ignore")
exec(open('rails.py').read().split('import time, subprocess, sys')[0]) # IOReport
_l = ctypes.CDLL("/usr/lib/libSystem.B.dylib"); _b = ctypes.create_string_buffer(464); _pid = os.getpid()
def cpu_nj():
_l.proc_pid_rusage(ctypes.c_int(_pid), ctypes.c_int(6), ctypes.byref(_b))
return int.from_bytes(_b.raw[336:344], "little")
def gpu_nj(s0, s1):
d = IOR.IOReportCreateSamplesDelta(s0, s1, None); t = 0
for g, sg, name, unit, val in rails(d):
if name == "GPU Energy":
assert unit == "nJ"; t += val
return t
PERIOD_NS = 10_000_000 # the measured, declared period of both counters
STATE = ("Guest in room 1408 says the AC has been out since yesterday and they want to "
"move tonight or leave. They also asked for the incidentals hold to be released.")
SCHEMA = {
"intent": ["maintenance", "room_change", "checkout", "billing", "complaint", "amenity_request"],
"priority": ["low", "normal", "high", "urgent"],
"needs_human": ["yes", "no"],
}
N = 60
def idle(secs):
s0 = sample(); c0 = cpu_nj(); t = time.perf_counter(); time.sleep(secs)
s1 = sample(); c1 = cpu_nj(); dt = time.perf_counter() - t
return gpu_nj(s0, s1) / dt, (c1 - c0) / dt
# ─── CPU: GLiNER2.5-Decide, 340M, Apache-2.0 ────────────────────────────────
import torch
torch.set_num_threads(os.cpu_count() or 8)
from gliner2 import AutoExtractor
t0 = time.perf_counter()
m = AutoExtractor.from_pretrained("fastino/GLiNER2.5-Decide")
load_s = time.perf_counter() - t0
try: m.model.to("cpu")
except Exception: pass
for _ in range(3): m.classify_text(STATE, SCHEMA)
pre_g, pre_c = idle(1.0)
s0 = sample(); c0 = cpu_nj(); t0 = time.perf_counter(); lat = []
for _ in range(N):
t = time.perf_counter(); out = m.classify_text(STATE, SCHEMA); lat.append((time.perf_counter()-t)*1e3)
span = time.perf_counter() - t0
s1 = sample(); c1 = cpu_nj()
post_g, post_c = idle(1.0)
cpu_j = (c1 - c0) / 1e9
gpu_leak = (gpu_nj(s0, s1) - (pre_g + post_g) / 2 * span) / 1e9
gliner = {
"model": "fastino/GLiNER2.5-Decide (340M, Apache-2.0, DeBERTa-v3-large)",
"fabric": "cpu", "attribution": "Process", "coverage": "Whole (CPU work)",
"load_s": round(load_s, 2), "decisions": N, "span_s": round(span, 3),
"p50_ms": round(statistics.median(lat), 2), "p95_ms": round(sorted(lat)[int(0.95*N)-1], 2),
"cpu_J_per_decision": round(cpu_j / N, 6),
"gpu_J_per_decision_leak": round(gpu_leak / N, 6),
"span_gradeable": span * 1e9 >= PERIOD_NS,
"answer": out,
}
print(json.dumps(gliner, indent=2, default=str))