Files
logan 4fa9a6ab81 feat(bench): automate B7 shader profiles, wire B12b trials, settle 45s
- gen_shader_profiles.py + shader-profiles/: Iris per-pack option files
  resolved from each pack's profile.* lines (Iris has no profile key)
- bench.py: flag rows shader_not_loaded when Iris falls back to vanilla
  rendering (analyze.py treats it as invalid)
- matrix: shader_options session key, trial jar globs pinned to the
  downloaded Modrinth builds, post-join settle 45 s (B0 v1 showed rep 1
  still warming at 10 s)
2026-10-16 23:34:52 -04:00

623 lines
25 KiB
Python

#!/usr/bin/env python3
"""Run the BENCH-MATRIX blocks declared in matrix.toml through `bench.py session`.
python3 matrix.py list # blocks, session counts, est. time
python3 matrix.py plan B3 [--param k=v] # the bench.py commands a block runs
python3 matrix.py run B0,B1 [--dry-run] # sequential, resumable
python3 matrix.py status # progress for the current mod set
Resume: ~/onion-bench/results/matrix-state.json records each session (key
"<block>#<index>") with a hash of its exact command and the instance mods/ hash; a
session is skipped only when both still match. A failed session (non-zero exit: crash,
timeout, no join) is retried once, then the run stops. Running any block other than B0
warns loudly when B0 was not run under the current mod set. Stdlib only.
"""
import argparse
import glob
import hashlib
import json
import os
import re
import shlex
import subprocess
import sys
import time
import tomllib
from pathlib import Path
HERE = Path(__file__).resolve().parent
MATRIX = HERE / "matrix.toml"
OUT_DIR = HERE.parent / "proposed-configs" / "out"
GAME = Path.home() / "onion-bench" / "prism" / "instances" / "OnionBench" / ".minecraft"
STATE = Path.home() / "onion-bench" / "results" / "matrix-state.json"
SCENE_ORDER = ["s1", "s2", "s2s", "s3", "s4", "s5"]
S5_SECONDS = 10 + 120 # bench.py: at S1, wait 10 s, then the 120 s tour (all captured)
JVM_SETS = {"J0", "J1", "J2", "J3"}
DISPLAYS = {"desktop", "gamescope", "gamescope-headless"}
PARAM_RE = re.compile(r"\$\{(\w+)\}")
TOP_KEYS = {"defaults", "worlds", "trials", "missing_variants", "block"}
DEFAULT_KEYS = {"reps", "warmup", "capture", "settle", "display", "res", "world", "shader", "dh",
"launch_overhead_s"}
BLOCK_KEYS = {"id", "title", "rule", "proxy_note", "manual_checks", "sessions", "params",
"deferred", "manual", "interleave"}
SESSION_KEYS = {"variant", "plan", "reps", "repeat", "tag", "jvm", "heap", "shader",
"shader_profile", "shader_options", "dh", "disable_mods", "add_mods", "world", "warmup",
"capture", "settle", "manual", "note", "display", "res"}
MISSING_KEYS = {"id", "blocks", "contents"}
# Flags `bench.py session` accepts; bench_argv must emit only these.
BENCH_SESSION_FLAGS = {
"--variant", "--plan", "--reps", "--warmup", "--capture", "--settle", "--label", "--jvm",
"--heap", "--boot-timeout", "--timeout", "--any-order", "--kill-stale", "--dry-run",
"--display", "--res", "--shader", "--shader-options", "--dh", "--disable-mods",
"--add-mods", "--world-zip",
}
class MatrixError(Exception):
pass
def log(msg):
print(f"[matrix] {msg}", flush=True)
# ------------------------------------------------------------------ loading / validation
def load(path=MATRIX, out_dir=OUT_DIR):
"""Parse + validate matrix.toml. Raises MatrixError listing every problem."""
with open(path, "rb") as f:
m = tomllib.load(f)
errors = validate(m, out_dir)
if errors:
raise MatrixError("matrix.toml invalid:\n " + "\n ".join(errors))
return m
def split_plan(plan):
if isinstance(plan, str):
return [s.strip() for s in plan.split(",") if s.strip()]
return list(plan)
def _pos_int(v):
return isinstance(v, int) and not isinstance(v, bool) and v > 0
def validate(m, out_dir=OUT_DIR):
err = []
for k in m:
if k not in TOP_KEYS:
err.append(f"unknown top-level key {k!r}")
d = m.get("defaults", {})
for k in d:
if k not in DEFAULT_KEYS:
err.append(f"defaults: unknown key {k!r}")
worlds, trials = m.get("worlds", {}), m.get("trials", {})
missing_ids = set()
for mv in m.get("missing_variants", []):
for k in mv:
if k not in MISSING_KEYS:
err.append(f"missing_variants {mv.get('id')}: unknown key {k!r}")
if not mv.get("id") or not mv.get("contents"):
err.append("missing_variants entry needs id and contents")
missing_ids.add(mv.get("id"))
_check_common(d, "defaults", worlds, trials, err)
seen = set()
for b in m.get("block", []):
bid = b.get("id")
where = f"block {bid}"
if not bid:
err.append("block without id")
continue
if bid in seen:
err.append(f"{where}: duplicate id")
seen.add(bid)
for k in b:
if k not in BLOCK_KEYS:
err.append(f"{where}: unknown key {k!r}")
if not b.get("rule"):
err.append(f"{where}: missing decision rule")
params = set(b.get("params", []))
sessions = b.get("sessions", [])
if not sessions and not (b.get("manual") or b.get("deferred")):
err.append(f"{where}: no sessions (mark manual or deferred)")
groups = set()
for i, s in enumerate(sessions):
sw = f"{where} session {i}"
for k in s:
if k not in SESSION_KEYS:
err.append(f"{sw}: unknown key {k!r}")
for k in ("variant", "jvm", "plan"):
v = s.get(k)
for p in PARAM_RE.findall(v if isinstance(v, str) else ",".join(v or [])):
if p not in params:
err.append(f"{sw}: ${{{p}}} not declared in params")
var = s.get("variant")
if not var:
err.append(f"{sw}: missing variant")
elif not PARAM_RE.search(var) and not (Path(out_dir) / var).is_dir() \
and var not in missing_ids:
err.append(f"{sw}: variant {var!r} not in {out_dir} nor in missing_variants")
plan = split_plan(s.get("plan", []))
if not plan:
err.append(f"{sw}: empty plan")
concrete = [p for p in plan if not PARAM_RE.search(p)]
for p in concrete:
if p not in SCENE_ORDER:
err.append(f"{sw}: unknown scene {p!r} (S6 is manual-only)")
idx = [SCENE_ORDER.index(p) for p in concrete if p in SCENE_ORDER]
if idx != sorted(set(idx)):
err.append(f"{sw}: plan must be in S1->S5 order without duplicates")
if "repeat" in s and not _pos_int(s["repeat"]):
err.append(f"{sw}: repeat must be a positive int")
if "heap" in s and not _pos_int(s["heap"]):
err.append(f"{sw}: heap must be a positive int (MB)")
jvm = s.get("jvm")
if jvm is not None and jvm not in JVM_SETS and not PARAM_RE.search(jvm):
err.append(f"{sw}: jvm {jvm!r} not in {sorted(JVM_SETS)}")
for t in s.get("add_mods", []):
if t not in trials:
err.append(f"{sw}: add_mods {t!r} not in [trials]")
if not isinstance(s.get("disable_mods", []), list):
err.append(f"{sw}: disable_mods must be a list")
_check_common(s, sw, worlds, trials, err)
g = (var, s.get("tag"))
if g in groups:
err.append(f"{sw}: variant {var!r} repeated without a distinct tag "
f"(analyze.py would merge them); use repeat or tag")
groups.add(g)
return err
def _check_common(s, where, worlds, trials, err):
for k in ("reps", "warmup", "capture", "settle"):
if k in s and not _pos_int(s[k]):
err.append(f"{where}: {k} must be a positive int")
if "display" in s and s["display"] not in DISPLAYS:
err.append(f"{where}: display {s['display']!r} not in {sorted(DISPLAYS)}")
if "res" in s and not re.fullmatch(r"\d+x\d+", str(s["res"])):
err.append(f"{where}: res must be WxH")
if "dh" in s and s["dh"] not in ("on", "off"):
err.append(f"{where}: dh must be on/off")
if "world" in s and s["world"] not in worlds:
err.append(f"{where}: world {s['world']!r} not in [worlds]")
if "shader_options" in s and s.get("shader", "off") == "off":
err.append(f"{where}: shader_options needs shader = <pack>")
def block_map(m):
return {b["id"]: b for b in m.get("block", [])}
def missing_variant_ids(m):
return {mv["id"] for mv in m.get("missing_variants", [])}
# ------------------------------------------------------------------ expansion / commands
def _subst(v, params):
if not isinstance(v, str):
return v
return PARAM_RE.sub(lambda mo: params.get(mo.group(1), mo.group(0)), v)
def expand(m, block, params=None, overrides=None):
"""Flat list of concrete sessions for a block (repeat / interleave applied)."""
params, overrides = params or {}, overrides or {}
d = dict(m.get("defaults", {}))
d.update({k: v for k, v in overrides.items() if v is not None})
groups = []
for si, s in enumerate(block.get("sessions", [])):
merged = {k: v for k, v in d.items() if k != "launch_overhead_s"}
merged.update(s)
for k in ("display", "res"): # CLI overrides beat per-session values too
if overrides.get(k) is not None:
merged[k] = overrides[k]
merged["variant"] = _subst(merged["variant"], params)
if merged.get("jvm") is not None:
merged["jvm"] = _subst(merged["jvm"], params)
merged["plan"] = [_subst(p, params) for p in split_plan(merged["plan"])]
tag = s.get("tag")
merged["label"] = f"{block['id']}.{tag}" if tag else block["id"]
merged["source"] = si
groups.append([dict(merged, run=r + 1) for r in range(s.get("repeat", 1))])
out = []
if block.get("interleave"):
for r in range(max((len(g) for g in groups), default=0)):
out += [g[r] for g in groups if r < len(g)]
else:
for g in groups:
out += g
for i, s in enumerate(out):
s["index"] = i
s["block"] = block["id"]
return out
def unresolved(sess):
"""Placeholder names still present in a session (missing --param)."""
vals = [sess["variant"], sess.get("jvm") or "", *sess["plan"]]
return sorted({p for v in vals for p in PARAM_RE.findall(v)})
def resolve_path(p):
p = os.path.expanduser(p)
hits = sorted(glob.glob(p))
return hits[-1] if hits else None
def shader_options_path(p):
"""shader_options are harness-relative unless absolute / ~."""
p = Path(os.path.expanduser(p))
return p if p.is_absolute() else HERE / p
def bench_argv(m, sess, live_world=False):
"""`bench.py session` argv for one session, plus a list of missing inputs."""
missing = []
a = ["session", "--variant", sess["variant"], "--plan", ",".join(sess["plan"]),
"--reps", str(sess.get("reps", 3)), "--label", sess["label"]]
for k in ("warmup", "capture", "settle"):
if k in sess:
a += [f"--{k}", str(sess[k])]
for k in ("display", "res", "shader", "dh", "jvm"):
if sess.get(k) is not None:
a += [f"--{k}", str(sess[k])]
if sess.get("heap"):
a += ["--heap", str(sess["heap"])]
if sess.get("shader_options"):
so = shader_options_path(sess["shader_options"])
if not so.is_file():
missing.append(f"shader options {so} (run gen_shader_profiles.py)")
a += ["--shader-options", str(so)]
if sess.get("disable_mods"):
a += ["--disable-mods", ",".join(sess["disable_mods"])]
if sess.get("add_mods"):
jars = []
for t in sess["add_mods"]:
pat = m["trials"][t]
hit = resolve_path(pat)
if hit is None:
missing.append(f"trial jar {t}: {pat}")
hit = os.path.expanduser(pat)
jars.append(hit)
a += ["--add-mods", ",".join(jars)]
if sess.get("world") and not live_world:
pat = m["worlds"][sess["world"]]
hit = resolve_path(pat)
if hit is None:
missing.append(f"world {sess['world']}: {pat} (or run with --live-world)")
hit = os.path.expanduser(pat)
a += ["--world-zip", hit]
return a, missing
def variant_missing(sess, out_dir=OUT_DIR):
return not (Path(out_dir) / sess["variant"]).is_dir()
def session_seconds(sess, overhead):
reps, w, c = sess.get("reps", 3), sess.get("warmup", 15), sess.get("capture", 30)
scenes = sum(S5_SECONDS if p == "s5" else w + c for p in sess["plan"]) * reps
return overhead + sess.get("settle", 10) + scenes
def spec_hash(argv, sess):
spec = {"argv": argv, "manual": bool(sess.get("manual"))}
if sess.get("shader_options"): # a regenerated profile file means a new spec
so = shader_options_path(sess["shader_options"])
spec["shader_options_sha256"] = (hashlib.sha256(so.read_bytes()).hexdigest()
if so.is_file() else None)
blob = json.dumps(spec, sort_keys=True)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
def fmt_cmd(argv):
return "python3 bench.py " + shlex.join(argv)
# ------------------------------------------------------------------ state
def mods_hash(mods_dir):
"""Same algorithm as bench.py mods_hash (name + size of every file in mods/)."""
h = hashlib.sha256()
files = sorted(p for p in Path(mods_dir).glob("*") if p.is_file())
for p in files:
h.update(f"{p.name}\0{p.stat().st_size}\n".encode())
return h.hexdigest()
class State:
def __init__(self, path):
self.path = Path(path)
self.data = {"version": 1, "sessions": {}, "b0": None}
if self.path.exists():
self.data.update(json.loads(self.path.read_text()))
def save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self.data, indent=1, sort_keys=True))
tmp.replace(self.path)
@staticmethod
def key(sess):
return f"{sess['block']}#{sess['index']}"
def entry(self, sess):
return self.data["sessions"].get(self.key(sess))
def is_done(self, sess, spec, mods):
e = self.entry(sess)
return bool(e and e["status"] == "ok" and e["spec"] == spec and e["mods_hash"] == mods)
def record(self, sess, spec, mods, status, rc, attempts):
self.data["sessions"][self.key(sess)] = {
"spec": spec, "mods_hash": mods, "status": status, "rc": rc,
"attempts": attempts, "label": sess["label"], "variant": sess["variant"],
"ts": time.strftime("%Y-%m-%dT%H:%M:%S")}
def b0_warning(state, current):
b0 = state.data.get("b0")
if not b0:
return "B0 (noise floor) has not been completed - win thresholds are undefined"
if b0["mods_hash"] != current:
return (f"MOD SET CHANGED since B0 (B0 under {b0['mods_hash'][:12]}, now "
f"{current[:12]}): B0 MUST BE RE-RUN before results are comparable")
return None
def banner(msg):
bar = "!" * 78
print(f"{bar}\n!! {msg}\n{bar}", file=sys.stderr, flush=True)
# ------------------------------------------------------------------ run
def default_runner(argv):
return subprocess.run([sys.executable, str(HERE / "bench.py"), *argv], cwd=HERE).returncode
def run_blocks(m, block_ids, state, mods_dir, runner=default_runner, params=None,
overrides=None, dry_run=False, include_manual=False, include_deferred=False,
live_world=False, skip_blocked=False, out_dir=OUT_DIR):
"""Run blocks in order. Returns 0 ok, 1 session failed, 2 preflight problem."""
bm = block_map(m)
for bid in block_ids:
if bid not in bm:
raise MatrixError(f"unknown block {bid}; known: {', '.join(bm)}")
# preflight: build every session's command, collect what can't run
todo, problems = [], []
for bid in block_ids:
b = bm[bid]
if b.get("deferred") and not include_deferred:
log(f"{bid}: deferred ({b.get('proxy_note', 'fleet-only')}) - skipped")
continue
if b.get("manual"):
log(f"{bid}: manual block - run by hand ({b.get('proxy_note', '')})")
continue
for s in expand(m, b, params, overrides):
if s.get("manual") and not include_manual:
log(f"{bid}#{s['index']} {s['label']}: manual - skipped")
continue
miss = [f"param ${{{p}}} (use --param {p}=...)" for p in unresolved(s)]
if not miss and variant_missing(s, out_dir):
miss.append(f"variant {s['variant']} not generated (see missing_variants)")
argv, inputs = bench_argv(m, s, live_world)
miss += inputs
if miss:
problems.append((s, miss))
continue
todo.append((s, argv))
for s, miss in problems:
log(f"BLOCKED {s['block']}#{s['index']} {s['label']}: " + "; ".join(miss))
if problems and not (skip_blocked or dry_run):
log("fix the above, or pass --skip-blocked (and/or --live-world)")
return 2
mods0 = mods_hash(mods_dir)
if "B0" not in block_ids and any(s["block"] != "B0" for s, _ in todo):
w = b0_warning(state, mods0)
if w:
banner(w)
total = len(todo)
for n, (s, argv) in enumerate(todo, 1):
spec = spec_hash(argv, s)
tagline = f"[{n}/{total}] {s['block']}#{s['index']} {s['label']}"
if state.is_done(s, spec, mods0):
log(f"{tagline}: done - skip")
continue
if dry_run:
log(f"{tagline}: {fmt_cmd(argv)}")
continue
now = mods_hash(mods_dir)
if now != mods0:
banner(f"instance mods/ changed during the run ({mods0[:12]} -> {now[:12]}); "
"a session did not restore it - stopping")
return 1
rc = None
for attempt in (1, 2):
log(f"{tagline}: attempt {attempt}: {fmt_cmd(argv)}")
rc = runner(argv)
if rc == 0:
break
log(f"{tagline}: exit {rc}" + (" - retrying once" if attempt == 1 else ""))
status = "ok" if rc == 0 else "failed"
state.record(s, spec, mods0, status, rc, attempt)
if s["block"] == "B0":
_maybe_close_b0(m, state, mods0, params, overrides, live_world)
state.save()
if status != "ok":
log(f"{tagline}: failed twice - stopping the run")
return 1
return 0
def _maybe_close_b0(m, state, mods, params, overrides, live_world):
b = block_map(m).get("B0")
if not b:
return
for s in expand(m, b, params, overrides):
argv, _ = bench_argv(m, s, live_world)
if not state.is_done(s, spec_hash(argv, s), mods):
return
state.data["b0"] = {"mods_hash": mods, "ts": time.strftime("%Y-%m-%dT%H:%M:%S")}
# ------------------------------------------------------------------ list / plan / status
def block_summary(m, b, overhead, out_dir=OUT_DIR):
ss = expand(m, b)
auto = [s for s in ss if not s.get("manual") and not b.get("manual")]
blocked = [s for s in auto if not unresolved(s) and variant_missing(s, out_dir)]
secs = sum(session_seconds(s, overhead) for s in auto)
return {"sessions": len(ss), "auto": len(auto), "manual": len(ss) - len(auto),
"blocked": len(blocked), "seconds": secs}
def cmd_list(m, a):
overhead = m.get("defaults", {}).get("launch_overhead_s", 60)
print(f"| block | title | sessions | auto | manual | blocked (variant) | est. min | notes |")
print("| --- | --- | --- | --- | --- | --- | --- | --- |")
tot = {"sessions": 0, "auto": 0, "seconds": 0}
for b in m.get("block", []):
s = block_summary(m, b, overhead)
notes = []
if b.get("deferred"):
notes.append("deferred")
if b.get("manual"):
notes.append("manual block")
if b.get("params"):
notes.append("params: " + ",".join(b["params"]))
print(f"| {b['id']} | {b.get('title', '')} | {s['sessions']} | {s['auto']} | "
f"{s['manual']} | {s['blocked']} | {s['seconds'] / 60:.0f} | {'; '.join(notes)} |")
if not b.get("deferred"):
for k in tot:
tot[k] += s[k]
print(f"\nnot deferred: {tot['sessions']} sessions ({tot['auto']} unattended), "
f"est. {tot['seconds'] / 3600:.1f} h unattended")
return 0
def cmd_plan(m, a):
bm = block_map(m)
for bid in a.blocks.split(","):
if bid not in bm:
raise MatrixError(f"unknown block {bid}")
b = bm[bid]
print(f"## {bid} {b.get('title', '')}" + (" (DEFERRED)" if b.get("deferred") else "")
+ (" (MANUAL)" if b.get("manual") else ""))
print(f"rule: {b['rule']}")
if b.get("proxy_note"):
print(f"proxy: {b['proxy_note']}")
for c in b.get("manual_checks", []):
print(f"manual check: {c}")
for s in expand(m, b, a.params, overrides(a)):
argv, miss = bench_argv(m, s, a.live_world)
pre = "# MANUAL: " if s.get("manual") else ""
print(f"{pre}{fmt_cmd(argv)}")
if s.get("shader_profile") and not s.get("shader_options"):
print(f"# set Iris profile {s['shader_profile']} for {s['shader']} first")
elif s.get("shader_profile"):
print(f"# Iris profile {s['shader_profile']} via shader options file")
if s.get("note"):
print(f"# note: {s['note']}")
if unresolved(s):
print(f"# needs --param {','.join(unresolved(s))}")
if variant_missing(s) and not unresolved(s):
print(f"# BLOCKED: variant {s['variant']} not generated")
for x in miss:
print(f"# missing: {x}")
print()
return 0
def cmd_status(m, a):
state = State(a.state)
mods = mods_hash(a.mods_dir) if Path(a.mods_dir).is_dir() else ""
w = b0_warning(state, mods)
if w:
banner(w)
print(f"mods hash {mods[:12]} | state {a.state}")
print("| block | done | failed | pending | manual/deferred |")
print("| --- | --- | --- | --- | --- |")
for b in m.get("block", []):
done = failed = pending = skipped = 0
for s in expand(m, b, a.params, overrides(a)):
if s.get("manual") or b.get("manual") or b.get("deferred"):
skipped += 1
continue
argv, _ = bench_argv(m, s, a.live_world)
e = state.entry(s)
if state.is_done(s, spec_hash(argv, s), mods):
done += 1
elif e and e["status"] == "failed":
failed += 1
else:
pending += 1
print(f"| {b['id']} | {done} | {failed} | {pending} | {skipped} |")
return 0
def cmd_run(m, a):
state = State(a.state)
return run_blocks(m, a.blocks.split(","), state, a.mods_dir, params=a.params,
overrides=overrides(a), dry_run=a.dry_run,
include_manual=a.include_manual, include_deferred=a.include_deferred,
live_world=a.live_world, skip_blocked=a.skip_blocked)
def overrides(a):
return {"display": getattr(a, "display", None), "res": getattr(a, "res", None)}
def parse_params(items):
out = {}
for it in items or []:
k, sep, v = it.partition("=")
if not sep:
raise MatrixError(f"--param needs name=value, got {it!r}")
out[k] = v
return out
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--matrix", default=str(MATRIX))
ap.add_argument("--state", default=str(STATE))
ap.add_argument("--mods-dir", default=str(GAME / "mods"))
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("list", help="blocks, session counts, estimated time")
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--param", action="append", default=[], help="name=value")
common.add_argument("--display", choices=sorted(DISPLAYS))
common.add_argument("--res")
common.add_argument("--live-world", action="store_true",
help="don't restore world zips; use saves/onionbench as-is")
p = sub.add_parser("plan", parents=[common], help="print the bench.py commands")
p.add_argument("blocks")
r = sub.add_parser("run", parents=[common], help="run blocks sequentially (resumable)")
r.add_argument("blocks", help="comma list, e.g. B0,B1")
r.add_argument("--dry-run", action="store_true")
r.add_argument("--include-manual", action="store_true")
r.add_argument("--include-deferred", action="store_true")
r.add_argument("--skip-blocked", action="store_true",
help="skip sessions with missing variants/inputs instead of aborting")
sub.add_parser("status", parents=[common], help="progress for the current mod set")
a = ap.parse_args(argv)
try:
a.params = parse_params(getattr(a, "param", []))
m = load(a.matrix)
return {"list": cmd_list, "plan": cmd_plan, "run": cmd_run,
"status": cmd_status}[a.cmd](m, a)
except MatrixError as e:
print(f"matrix.py: {e}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())