diff --git a/tools/cycle_testrom/.gitignore b/tools/cycle_testrom/.gitignore index 82ff4e93..71ef760c 100644 --- a/tools/cycle_testrom/.gitignore +++ b/tools/cycle_testrom/.gitignore @@ -2,3 +2,12 @@ cycle_testrom.exe *.anchors.json generated/ + +# Synthetic boot disc — LOCAL ONLY, never commit/push. +# disc/license_data.dat is the PlayStation license region extracted from a disc +# the developer OWNS (via dumpsxiso). It is copyrighted Sony data: do NOT commit, +# do NOT redistribute. Each developer extracts their own from a disc they own +# (see README "Boot disc"). The built .bin/.cue embed it, so they are ignored too. +disc/license_data.dat +disc/*.bin +disc/*.cue diff --git a/tools/cycle_testrom/README.md b/tools/cycle_testrom/README.md index 507d2e10..02170814 100644 --- a/tools/cycle_testrom/README.md +++ b/tools/cycle_testrom/README.md @@ -43,17 +43,59 @@ Each component is then transcribed/calibrated into `psx_instr_base_cycles` (execute latencies: mult/div, GTE) or the memory-path wait-state (`memory.c`), Δ-gated against Beetle on these loops + FMV-no-regression, one at a time. -## Regenerate +## Boot disc (how the EXE reaches both backends) + +The real BIOS only boots a disc that carries the PlayStation license region; a +license-less disc drops to the BIOS shell (Memory-Card/CD-Player menu). So the +test EXE ships on a synthetic disc built with the in-tree `tools/mkpsxiso`: ```bash cd tools/cycle_testrom -python gen_testrom.py cycle_testrom.exe # writes the EXE + .anchors.json -# recompile for native: -../../recompiler/build-t2/psxrecomp-game.exe --config game.toml +python gen_testrom.py cycle_testrom.exe # EXE + .anchors.json +../../recompiler/build-t2/psxrecomp-game.exe --config game.toml # recompile (native) + +# ONE-TIME: extract the license region from a disc YOU OWN (local only, never +# redistributed). dumpsxiso writes license_data.dat from the disc system area: +DUMP=../mkpsxiso/mkpsxiso-2.20-win64/dumpsxiso.exe +"$DUMP" -x /tmp/own_disc -s /tmp/own_disc/x.xml ".cue" +cp /tmp/own_disc/license_data.dat disc/license_data.dat # gitignored, stays local + +# build the disc (embeds license + SYSTEM.CNF + cyctest.exe): +cd disc && ../../mkpsxiso/mkpsxiso-2.20-win64/mkpsxiso.exe -y cyctest.xml ``` -Tracked: `gen_testrom.py`, `game.toml`, `seeds.txt`, this README. -Ignored (regenerable): `cycle_testrom.exe`, `*.anchors.json`, `generated/`. +`disc/license_data.dat` and the built `disc/*.bin`/`*.cue` are **gitignored** — +copyrighted Sony data, local only. Everything else (gen, xml, SYSTEM.CNF) is +tracked, so each developer reproduces the disc from a disc they own. + +## Measure + +```bash +# both backends boot the same disc/cyctest.cue: +# beetle: psx-beetle --disc disc/cyctest.cue --port 4382 +# native: ... --disc disc/cyctest.cue (TODO: test-runtime) +python measure.py --port 4382 # Beetle oracle: per-iter + per-component delta +python measure.py --port 4500 # native cost model (once the test-runtime exists) +``` + +## Beetle ORACLE results (2026-06-26 — the HW cost targets) + +Per-iteration cycle delta, and component cost (minus baseline=3): + +| loop | per-iter | component | reads as | +|-------------|----------|-----------|----------| +| baseline | 3 | — | addiu+bne+nop, base 1 each | +| alu | 4 | +1 | one addu (pure execute) | +| load | 8 | +5 | one main-RAM lw (result unused → no absorb) | +| load2 | 14 | +11 | 2 lw: 5 + 6 (2nd load's +1 = ReadFudge) | +| div | 41 | +38 | divu+mflo base 2 + ~36 div stall | +| div_spaced | 41 | +38 | divu+2addu+mflo — fillers ABSORBED by stall | +| mult | 18 | +15 | multu+mflo base 2 + ~13 mult stall | + +Key targets for the native cost model: **div stall ~36, mult stall ~13** (native +charges 0). Load ~5/6 (native memory.c flat +6 — close). **`div_spaced`==`div` +proves the stall must be modeled as a completion-timestamp + stall-on-mflo-read +(fillers absorb it), NOT a flat charge on the divu.** ## Status / TODO diff --git a/tools/cycle_testrom/disc/SYSTEM.CNF b/tools/cycle_testrom/disc/SYSTEM.CNF new file mode 100644 index 00000000..2a5fc220 --- /dev/null +++ b/tools/cycle_testrom/disc/SYSTEM.CNF @@ -0,0 +1,4 @@ +BOOT = cdrom:\CYCTEST.EXE;1 +TCB = 4 +EVENT = 10 +STACK = 801FFF00 diff --git a/tools/cycle_testrom/disc/cyctest.xml b/tools/cycle_testrom/disc/cyctest.xml new file mode 100644 index 00000000..c4fa189a --- /dev/null +++ b/tools/cycle_testrom/disc/cyctest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/tools/cycle_testrom/measure.py b/tools/cycle_testrom/measure.py new file mode 100644 index 00000000..39121ef9 --- /dev/null +++ b/tools/cycle_testrom/measure.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""measure.py — per-iteration cycle delta for each test-ROM anchor on one backend. + +Arms cyc_watch single-anchor on each loop top, dumps, and reports the modal +consecutive-hit delta = one iteration's cycle cost. Run against Beetle (4382, +the oracle) and native (the cost model) and compare. + +Usage: python measure.py [--port 4382] [--json cycle_testrom.exe.anchors.json] +""" +import argparse, collections, json, socket, sys, time + +def query(port, cmd, host="127.0.0.1", timeout=10.0): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout); s.connect((host, port)) + try: + s.sendall((json.dumps(cmd) + "\n").encode()) + buf = bytearray(); depth = 0; instr = False; esc = False; started = False + while True: + ch = s.recv(1 << 20) + if not ch: break + buf.extend(ch) + i = 0 + while i < len(buf): + c = buf[i] + if instr: + if esc: esc = False + elif c == 0x5C: esc = True + elif c == 0x22: instr = False + elif c == 0x22: instr = True + elif c == 0x7B: depth += 1; started = True + elif c == 0x7D: + depth -= 1 + if started and depth == 0: + return json.loads(buf[:i+1].decode()) + i += 1 + finally: + s.close() + return json.loads(bytes(buf).decode()) + +def modal_delta(entries): + cy = [int(e["cycles"]) for e in entries] + ds = [cy[i+1]-cy[i] for i in range(len(cy)-1)] + if not ds: return None, ds + return collections.Counter(ds).most_common(1)[0][0], ds + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=4382) + p.add_argument("--json", default="cycle_testrom.exe.anchors.json") + p.add_argument("--hits", type=int, default=8) + p.add_argument("--wait", type=float, default=1.5) + a = p.parse_args() + meta = json.load(open(a.json)) + anchors = meta["anchors"] + label = "beetle" if a.port == 4382 else ("native" if a.port == 4500 else f"port{a.port}") + print(f"=== per-iteration cycle delta on {label} (port {a.port}) ===") + results = {} + base = None + for name, addr in anchors.items(): + query(a.port, {"cmd": "cyc_watch", "pc": addr, "n": a.hits}) + time.sleep(a.wait) + d = query(a.port, {"cmd": "cyc_watch_dump"}) + md, ds = modal_delta(d.get("entries", [])) + results[name] = md + if name == "baseline": base = md + comp = f" (component = {md - base:+d})" if (base is not None and md is not None and name != "baseline") else "" + print(f" {name:12s} {addr} per-iter={md}{comp} hits={d.get('hits')}") + print() + if base is not None: + print("Component costs (per-iter minus baseline):") + for name, md in results.items(): + if name == "baseline" or md is None: continue + print(f" {name:12s} {md - base:+d}") + return 0 + +if __name__ == "__main__": + sys.exit(main())