Files
BFM-decomp/tools/gate_wave.py
T
Drew T 6bb3d240fa feat(p31 s67): harvest §325-§331 + gate_wave.py (split jtbl/parallel, both lanes concurrent)
HARVEST — the s67o1/s67m1 wave banked 7 cookbook sections:
* §325 a shared small constant stored twice in the pre-loop block is a LOCAL-ALLOC $s-occupant that
  steals the argument allocno's register — pin the ARGUMENT-derived local, not the constant
  (pinning the constant reached only closeness 15). byte-proven func_80184F18.
* §326 spelling two reads of the same halfword differently (sym[i] vs *(s16*)(base+i*4+2)) yields
  different address rtx and DEFEATS address-CSE, restoring separate %hi/%lo groups. func_8017FAAC.
* §327 a range test must be HImode: with s32 + a (u16) cast gcc PROVES the mask redundant and drops
  the andi — a real -1 length drift that reads as a schedule. +3 levers. func_8017EC34.
* §328 NEW LAW: the volatile alias must be an aliased OBJECT; `*(volatile s32*)&sym` unfolds %lo
  into a separate addiu (+1 ins). func_80181B8C.
* §329 fold-const narrows `(int)s16 & 0xFFF` onto the RAW HImode pseudo, breaking the
  sign-extend/mask register tie; a zero-byte `s32 e = t;` widening temp restores it (30 rows -> 0).
* §330 the NEIGHBOUR-SHAPE lever, four independent instances in one wave — copy an already-banked
  in-TU function's SPELLING before any codegen reasoning (one dissolved 18 REGALLOC-PERM rows in a
  single compile). Corollary: a warm start from another binary is often worth LESS than the
  neighbour 20 lines away.
* §331 OPEN GAP, recorded as unsolved: no lever eliminates an UNWANTED DUPLICATE copy at a
  branch-target block head (main/func_80013154, closeness 12, ~16 iterations, 5 approaches refuted).

TOOLIFY — tools/gate_wave.py: split the batch on the per-draft jtbl predicate, run parallel_gate
and the serial jtbl lane CONCURRENTLY. Measured this session: 4 binaries in 103s wall through
parallel_gate (87/87/88/102s each) vs ~6 min serially; I had gated all 16 serially to protect ONE
jtbl draft, ~1 hour. The split precedes the run because a jtbl worker does NOT fail cleanly — it
re-extracts through the worktree's asm/ symlink and writes the MAIN tree while other workers read it.

Its own negative control found two defects in it before first use:
  * listdir counted gate_stage's _xform output dirs (-cn/-cast/-rc/-sd, written as SIBLINGS inside
    the drafts root) as binaries: 20 "binaries" for a 16-binary wave. Now validated against
    progress.BINARIES and refused loudly (R32/R43).
  * a post-hoc control over BANKED functions cannot reproduce a split (has_jtbl has no stub to read);
    re-controlled against a live draft set, where it correctly routes the two functions the gate had
    independently reported CARVE-REFUSED.
2026-08-31 12:44:13 -06:00

166 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""gate_wave.py — gate a whole wave the fast way: split on jtbl, run both lanes CONCURRENTLY. (P31 S67)
WHY THIS EXISTS (measured, S67, and it cost an hour). `parallel_gate` cannot host a jtbl-bearing
draft: `harvest_verify`'s carve runs `make extract`, and a worker's worktree `asm/` is a SYMLINK to
the main tree (`parallel_gate.py:77` states the invariant — "a gate never writes them, only
`make extract` does"). Knowing that, I gated a 16-binary wave SERIALLY to protect the ONE jtbl
function in it. Numbers from that run:
4 binaries through parallel_gate (4 workers) : 103 s wall (87 / 87 / 88 / 102 s each)
the same 4 serially : ~6 min
the full 16 serially : ~1 hour, for 1 jtbl draft
Applying a true rule to the wrong scope is the defect. The predicate is per-DRAFT and it is cheap:
`jtbl_carve --probe` runs the real planner (since S67), so the split costs one probe per target.
WHY THE SPLIT PRECEDES THE RUN, rather than triaging failures afterwards. A jtbl draft in a
parallel worker does NOT fail cleanly — it re-extracts through the shared `asm/` symlink and writes
the MAIN tree while nine other workers read it. "Run everything parallel and re-run what failed"
can therefore poison the whole batch instead of isolating one draft. Split first, then triage what
the filter missed (R32: verify the pre-filter against the outcome; never assume it was complete).
tools/gate_wave.py --drafts .run/S67_ov --workers 8 --commit [--r22]
<drafts>/<binary>/<fn>.c — the layout gate_stage and parallel_gate both take
"""
import argparse
import concurrent.futures as cf
import json
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, HERE)
PY = os.path.join(REPO, ".venv/bin/python")
_JTBL_RE = re.compile(r"jtbl_[0-9A-Fa-f]{8}")
import corpus # noqa: E402
import progress # noqa: E402
def sh(cmd, **kw):
return subprocess.run(cmd, cwd=REPO, capture_output=True, text=True, **kw)
def has_jtbl(binary, fn):
"""Does this function's target .s reference a jump table?
The SAME predicate `harvest_verify._fn_has_jtbl` uses to decide whether to carve — one
definition of the question, so the router and the gate cannot disagree (R33/R34).
"""
st = next((s for s in corpus.stubs(binary).values() if s.symbol == fn), None)
if st is None:
return False # already banked; the gate will no-op it
try:
return bool(_JTBL_RE.search(open(os.path.join(REPO, st.asm_path), errors="replace").read()))
except OSError:
return False
def split(drafts_root):
"""(parallel_plan, serial_items) — parallel is a plan.json for parallel_gate; serial is
[(binary, [fn...])] for gate_stage."""
par, ser = [], []
known = set(progress.BINARIES)
unknown = []
for binary in sorted(os.listdir(os.path.join(REPO, drafts_root))):
d = os.path.join(REPO, drafts_root, binary)
if not os.path.isdir(d):
continue
# THE LADDER LEAVES ITS OWN OUTPUT DIRS BESIDE THE DRAFTS. `gate_stage._xform` writes
# `<drafts>-cn`, `-cn-cast`, `-cn-cast-rc`, `-cn-cast-rc-sd` as SIBLINGS inside this root,
# so a plain listdir reports 20 "binaries" for a 16-binary wave and would gate transformed
# intermediates as if they were fresh work. Validate the name against the corpus and refuse
# what is not a binary (R32: assert the denominator; R43: refuse, never mishandle).
if binary not in known:
unknown.append(binary)
continue
fns = sorted(f[:-2] for f in os.listdir(d) if f.endswith(".c"))
if not fns:
continue
jt = [f for f in fns if has_jtbl(binary, f)]
# A binary goes SERIAL if ANY of its drafts carries a table: the gate runs per binary, and
# one carving draft is enough to make the whole worker unsafe.
(ser if jt else par).append((binary, fns, jt))
if unknown:
print(" skipped %d non-binary dir(s) (ladder intermediates): %s"
% (len(unknown), " ".join(sorted(unknown)[:6])), file=sys.stderr)
plan = [{"binary": b, "drafts": os.path.join(REPO, drafts_root, b)} for b, _, _ in par]
return plan, ser, par
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--drafts", required=True, help="<dir>/<binary>/<fn>.c")
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--commit", action="store_true")
ap.add_argument("--r22", action="store_true")
ap.add_argument("--source-tag", default="wave")
ap.add_argument("--plan-only", action="store_true")
a = ap.parse_args()
plan, ser, par = split(a.drafts)
n_par = sum(len(f) for _, f, _ in par)
n_ser = sum(len(f) for _, f, _ in ser)
print("split: %d binaries / %d drafts PARALLEL · %d binaries / %d drafts SERIAL (jtbl)"
% (len(plan), n_par, len(ser), n_ser))
for b, fns, jt in ser:
print(" serial %-16s %s (jtbl: %s)" % (b, " ".join(fns), " ".join(jt)))
if a.plan_only:
return 0
if not plan and not ser:
print("REFUSING: no drafts found under %s" % a.drafts, file=sys.stderr)
return 2
plan_path = os.path.join(REPO, ".run/gate_wave_plan.json")
with open(plan_path, "w") as fh:
json.dump(plan, fh, indent=1)
def run_parallel():
if not plan:
return "(no parallel lane)"
cmd = [PY, "tools/parallel_gate.py", "--plan", ".run/gate_wave_plan.json",
"--workers", str(a.workers)]
if a.commit:
cmd.append("--commit")
r = sh(cmd, timeout=14400)
return (r.stdout or "") + (r.stderr or "")
def run_serial():
out = []
for b, _, _ in ser:
cmd = [PY, "tools/gate_stage.py", "--binary", b,
"--drafts", os.path.join(a.drafts, b), "--source-tag", a.source_tag]
if a.commit:
cmd.append("--commit")
r = sh(cmd, timeout=7200)
out.append("[serial] %s rc=%d %s" % (b, r.returncode, (r.stdout or "").strip()[-200:]))
return "\n".join(out)
# BOTH LANES AT ONCE (Drew, S67: no lane should idle). They are safe together — parallel_gate's
# workers are isolated by construction and it adopts a file only if the main tree's copy still
# matches the pinned baseline, REFUSING rather than clobbering if the serial lane moved it.
with cf.ThreadPoolExecutor(max_workers=2) as ex:
fp, fs = ex.submit(run_parallel), ex.submit(run_serial)
print(fp.result())
print(fs.result())
if a.r22:
print("[gate_wave] R22 clean-fleet ...")
r = sh(["make", "clean"], timeout=3600)
r = sh(["make", "extract-all"], timeout=14400)
r = sh(["make", "check-all"], timeout=14400)
tail = ((r.stdout or "") + (r.stderr or "")).strip().splitlines()[-3:]
print("\n".join(tail))
if r.returncode:
print("R22 FAILED — do not report banks as done (R22/R50)", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())