docs(timing): empirically-derived load ReadFudge/LDAbsorb model + impl spec

Measured Beetle's per-instruction cost through the ruler #2 load loops via
adjacent-PC region cyc_watch (Beetle observes per-instruction; native compiled
emitter observes only at block leaders). Nails the last load-path component:
  - fudge = +2 iff the previous instruction committed no pending load
    (ReadFudge=0x20; (reg>>4)&2 is 0 for all real regs), else 0 — NOT per-register
  - region+completion = 5 (main RAM); LDAbsorb give-back excludes fudge
  - the load-delay-slot instruction does not absorb (its §1 precedes its DO_LDS);
    instructions after it do, until a GPR_DEP/RES of the loaded reg ends it
  - Beetle per-insn: load = lw7/addiu1/bne0/nop0; load2 = lw7/lw6/addiu1/bne0/nop0

This is the de-risked spec for a pervasive per-instruction implementation
(ReadAbsorb[]/ReadAbsorbWhich/LDWhich in both emitters + the dirty interp),
validatable via the ruler-loop anchors (baseline 3 / load 8 / load2 14). Read-only
derivation; no runtime/emitter code changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-27 13:12:57 -07:00
parent 0c633d8708
commit da3ab3bdd1
2 changed files with 97 additions and 0 deletions
+13
View File
@@ -213,6 +213,19 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-06-27 (load ReadFudge/LDAbsorb — MODEL NAILED empirically, impl pending):**
Derived the last load-path component (the residual on both rulers) by measuring
Beetle's PER-INSTRUCTION cost via adjacent-PC region cyc_watch. Confirmed:
fudge = +2 iff the previous instruction committed no pending load (ReadFudge=0x20;
`(reg>>4)&2` is 0 for all real regs), else 0; region+completion=5 (LDAbsorb excludes
fudge); the load-delay-slot instruction does NOT absorb (its §1 precedes its DO_LDS),
the instructions after it do. Per-instruction Beetle data: load = lw7/addiu1/bne0/nop0;
load2 = lw7/lw6/addiu1/bne0/nop0. Full model + implementation spec in
`accuracy/load_readfudge_ldabsorb.md`. Implementation is pervasive (per-instruction
ReadAbsorb + GPR_DEP/RES in both emitters + interp) → a focused next task on a clean
tree; validate via the ruler-loop anchors (baseline 3 / load 8 / load2 14). No code
changed this step (read-only empirical derivation).
- **2026-06-27 (interp-path cycle ruler — enabler DONE + validated):** The dirty
interp now emits `debug_server_cyc_observe(pc)` per instruction (gated), so
interp-executed PCs are cyc_watch-anchorable (were not). Validated: a live
+84
View File
@@ -0,0 +1,84 @@
# Load ReadFudge / LDAbsorb — empirically-derived model + implementation spec
Status: **MODEL NAILED (empirically, 2026-06-27), implementation PENDING.** This is
the last unmodeled piece of the load path — the residual on BOTH rulers
(ruler #1 native 54 vs Beetle 56; ruler #2 `load2` native +10 vs Beetle +11). It
is the R3000A load-delay pipeline interlock (Beetle `ReadFudge`/`ReadAbsorb[]`/
`LDAbsorb`/`LDWhich`). Derived by measuring Beetle's PER-INSTRUCTION cost via
adjacent-PC region cyc_watch (Beetle observes per-instruction; the native compiled
emitter observes only at block leaders, so native per-insn is not observable — Beetle
is the per-instruction oracle here).
## Empirical per-instruction costs (Beetle oracle, ruler #2 loops)
| loop | per-instruction Δ (cycles) | per-iter |
|-----------|---------------------------------------------|----------|
| baseline | addiu **1**, bne **1**, nop **1** | 3 |
| load | lw **7**, addiu **1**, bne **0**, nop **0** | 8 (+5) |
| load2 | lw1 **7**, lw2 **6**, addiu **1**, bne **0**, nop **0** | 14 (+11) |
Reading these:
- **1st lw = 7**: fudge 2 + region 3 + completion 2. The +2 fudge appears because the
instruction before it (nop / branch) committed NO load.
- **2nd lw = 6**: fudge **0** (prev instruction was a load) + region 3 + completion 2
+ **its own §1 base +1 (NOT absorbed)**. = 0+3+2+1.
- **delay-slot instruction (addiu) stays 1**: its §1 runs BEFORE its own DO_LDS sets
`ReadAbsorb[loaded]`, so it cannot absorb the load it follows.
- **bne, nop = 0**: absorbed from the load's `LDAbsorb` give-back.
## The model (Beetle cpu.cpp, confirmed by the table above)
Per instruction, in order:
1. **fetch** (§2, separate axis — I-cache; 0 for cached-RAM steady state).
2. **§1 base** (cpu.cpp:795-798): `if (ReadAbsorb[ReadAbsorbWhich]) ReadAbsorb[ReadAbsorbWhich]--; else timestamp++;`
— `ReadAbsorbWhich` = the GPR the most-recently-committed load wrote.
3. **GPR_DEP/RES** (cpu.cpp:702-705): for each source/dest reg of this instruction,
`ReadAbsorb[reg]=0` (save/restore `ReadAbsorb[0]`). This ends the give-back when the
loaded value is actually used (load-use hazard).
4. **execute**; a load (LW/LH/.../LWC2) runs ReadMemory (§3, cpu.cpp:364-451):
- `timestamp += (ReadFudge>>4)&2` — the **fudge**. NOTE: `ReadFudge` holds the last
load's dest reg (0-31) OR **0x20** when the previous instruction committed no load.
`(x>>4)&2` is **0 for any reg 0-31** and **2 only for 0x20**. So fudge = **+2 iff the
previous instruction committed no pending load**, else 0. (This is the non-obvious
part — it is NOT per-register.)
- region wait-state (main RAM = 3; scratchpad 0x1F800000-0x1F8003FF = 0 early-return;
other regions differ), then `+2` (CPU load) or `+1` (LWC2/GTE load).
- `LDAbsorb = lts - timestamp` = **region + completion (EXCLUDES fudge)** = 5 for main RAM.
5. **DO_LDS** (cpu.cpp:800), at the END of the handler: commits the PREVIOUS instruction's
pending load: `GPR[LDWhich]=LDValue; ReadAbsorb[LDWhich]=LDAbsorb; ReadFudge=LDWhich;
ReadAbsorbWhich |= LDWhich&0x1F; LDWhich=0x20;`. When `LDWhich==0x20` (no pending),
`ReadFudge` becomes 0x20 → next load gets +2 fudge.
Stores are posted (+0, already modeled). Scratchpad loads are +0 (early-return, LDAbsorb=0).
## Why our current model is off
Current: flat `+1` base per instruction (PSX_CODEGEN_CYCLE_PER_INSN) + `memory.c`
charges a flat region wait-state (PSX_RAM_READ_WAIT_CYCLES=4) per RAM read. This
captures region+completion≈5 for an isolated load (matches `load`=+5) but MISSES:
(a) the **fudge** (+2 on a load whose predecessor wasn't a load → `load2` 2nd lw),
(b) the **absorb give-back** (following instructions going free), and
(c) scratchpad=+0. (a) and (b) happen to partially cancel on the `load` loop (why
single-load already matches) but not on `load2` / dependent-use sequences.
## Implementation plan (pervasive — a focused next task)
1. CPUState (append at end): `ReadFudge`, `ReadAbsorb[33]`, `ReadAbsorbWhich`,
`LDWhich`, `LDAbsorb`. (`LDValue`/value-commit already handled by the existing
load-delay-slot correctness path — only the TIMING state is new.)
2. `psx_cycles.c`: helpers for §1 (`psx_cyc_base`), the load cost+fudge+LDAbsorb arm,
and DO_LDS timing-commit. region wait-state stays address-keyed in `memory.c` but
must feed LDAbsorb (region+completion), and set fudge from ReadFudge.
3. Both emitters (code_generator.cpp, strict_translator.cpp) + dirty interp: replace the
flat per-instruction `+1` with the §1 call, and emit `GPR_DEP/RES` zeroing for each
instruction's source/dest registers (the pervasive part — every opcode passes its
regs). Loads arm LDWhich/LDAbsorb; the next instruction's timing-DO_LDS commits.
4. Interaction with muldiv/GTE stalls: those use `*_ts_done` deadlines independent of
ReadAbsorb — keep them; just ensure the §1 base replacement doesn't double-count.
5. **Validate** via the existing ruler-loop anchors (observable at loop tops): `baseline`=3,
`load`=8, `load2`=14 must match Beetle EXACTLY; ruler #1 [c5c→ca4] should reach 56;
FMV no-regression. Add a load-then-dependent-use loop to ruler #2 to pin the
GPR_DEP give-back termination.
Risk: pervasive per-instruction emit change to the cost model — do it on a clean tree
with full budget, validate one ruler loop at a time.