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]>
71 lines
3 KiB
Python
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))
|