timing: faithful R3000A I-cache fetch model + interp validation (Stage 1)

New runtime/src/psx_icache.c: direct-mapped (4 KB / 256-line) instruction-
cache fetch cost, transcribed from Beetle PS_CPU::ReadInstruction:
  HIT  (TV == addr)            -> +0, no give-back clear
  MISS, KSEG1/uncached         -> +4 (BIOS ROM)
  MISS, cached (KSEG0/KUSEG)   -> +3, then +1 per word refilled from the
                                  missing word to the line end (earlier words
                                  stay invalid); also clears the load give-back.
Mirrors only the per-word TV tag array (cost + tag state; the value is already
in hand). Wired into the dirty-RAM interpreter (exec_one) per instruction,
charged BEFORE the §1 base — Beetle order.

New ruler #2 loop `icache_miss`: loop top + a victim 0x1000 apart alias the
same direct-mapped line with different tags, forcing a refill miss every
iteration (other loops are all-hits after warm-up).

VALIDATED interp vs Beetle (PSX_FORCE_INTERP=1 PSX_ICACHE=1): icache_miss
native +14 == Beetle +14 (per-iter 17); the other 12 loops unchanged at +0
fetch. So the I-cache hit AND refill-miss costs are MEASURED equal to the
oracle on the interp path.

Opt-in via PSX_ICACHE=1 (default OFF) — charging fetch cost in only one
backend would fork mixed compiled/interp timing. Production activation +
the compiled-path emit (per cache-line leader) + ruler #1 cold-spike
validation are Stage 2. Default-off => no production change (the interp's
fetch call is a no-op without the env).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-27 16:48:56 -07:00
parent 9bc12fb510
commit 958a928d14
7 changed files with 152 additions and 1 deletions
+7
View File
@@ -111,6 +111,13 @@ extern uint32_t psx_mult_latency_u(uint32_t rs); /* MULTU (unsigned) */
* deadline first. Every COP2 register access calls psx_gte_stall, which advances
* guest cycles to the deadline if the op is still in flight. Faithful only with
* per-instruction cycle charging. */
/* R3000A I-cache instruction-FETCH cost (psx_icache.c). Faithful direct-mapped
* model: HIT +0, KSEG1/uncached +4, cached miss +3 + refill. Shared by interp
* (per-instruction) and the compiled emitters (per-cache-line leader). */
extern void psx_icache_fetch(CPUState* cpu, uint32_t addr);
extern void psx_icache_reset(void);
extern int psx_icache_enabled(void); /* opt-in (PSX_ICACHE=1) until both backends charge it */
extern void psx_gte_set(CPUState* cpu, uint32_t latency);
extern void psx_gte_stall(CPUState* cpu);
/* MFC2/CFC2 GTE register read: stall to the deadline AND arm the load-delay
+1
View File
@@ -147,6 +147,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/iso_reader.cpp
${PSXRECOMP_ROOT}/runtime/src/iso_reader_c.cpp
${PSXRECOMP_ROOT}/runtime/src/psx_cycles.c
${PSXRECOMP_ROOT}/runtime/src/psx_icache.c
${PSXRECOMP_ROOT}/runtime/src/starvation_ring.c
${PSXRECOMP_ROOT}/runtime/src/latency_ring.c
${PSXRECOMP_ROOT}/runtime/src/card_read_summary.c
+6 -1
View File
@@ -920,13 +920,18 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
*next_pc_out = pc + 4;
#ifdef PSX_ENABLE_BLOCK_CYCLES
/* Instruction FETCH cost (I-cache) — charged FIRST, before the §1 base, exactly
* like Beetle ReadInstruction precedes the per-instruction base (cpu.cpp). HIT=+0,
* KSEG1=+4, cached miss=+3+refill; a miss also clears the load give-back. */
psx_icache_fetch(cpu, pc);
/* Per-instruction R3000A load-delay interlock (single-source: psx_cyc.h, shared
* with both static emitters). §1 base + GPR_DEPRES + DO_LDS run HERE, before the
* instruction body, so §1 precedes any muldiv/GTE deadline stall the body applies
* (Beetle order). CPU loads (op 0x20-0x26) are skipped here — psx_cyc_load_* runs
* their full interlock inside the body (and arms LDWhich=rt). This replaces the
* old flat per-instruction psx_advance_cycles(psx_instr_base_cycles). */
#ifdef PSX_ENABLE_BLOCK_CYCLES
if (!(opc >= 0x20u && opc <= 0x26u))
psx_cyc_step(cpu, psx_cyc_dep_res_mask(insn));
#endif
+1
View File
@@ -2645,6 +2645,7 @@ int main(int argc, char** argv) {
/* R3000A load-delay interlock init (Beetle: BACKED_LDWhich=0x20 = no pending
* load; ReadFudge=0 so the first load gets no fudge). Rest is correctly 0. */
cpu.ld_which_t = 0x20;
psx_icache_reset(); /* all I-cache lines cold at reset */
/* Wire memory function pointers. Guest data loads now route their CPU cycle
* cost through the faithful load-delay interlock (psx_cyc_load_* in memory.c),
+92
View File
@@ -0,0 +1,92 @@
/* psx_icache.c — R3000A instruction-cache FETCH cost model (faithful).
*
* FAITHFUL_TIMING_PLAN.md axis-2 (I-cache). Transcribed from the in-tree Beetle
* oracle PS_CPU::ReadInstruction (psxrecomp/beetle-psx/mednafen/psx/cpu.cpp:534-601).
*
* The R3000A I-cache is direct-mapped, 4 KB = 1024 words = 256 lines x 4 words
* (16-byte lines). Beetle models it as ICache[(addr&0xFFC)>>2].TV (the per-word
* tag+validity); a fetch HITS iff TV == addr (exactly). We mirror only the TV
* array (we don't need the cached Data — the recompiler/interp already have the
* instruction; we only charge the FETCH cycle cost and clear the load give-back on
* a miss, exactly like ReadInstruction).
*
* Per fetch:
* HIT (TV == addr): +0, and (crucially) NO load-delay give-back clear.
* MISS (TV != addr): clear the pending load give-back (cpu.cpp:542-543), then:
* - KSEG1 / cache-disabled (addr >= 0xA0000000): +4, no fill (BIOS ROM case).
* - cached (KSEG0/KUSEG): +3, then +1 per word REFILLED from the missing word
* to the END of the 4-word line; words before the missing one stay INVALID.
* So a miss at word W refills W..3 (cost 3 + (4-W)); a later access to a word
* < W in that line misses again. Steady-state hot loops are all hits (+0).
*
* Residuals (documented, narrow): the BIU cache-enable bit and the SR cache-isolation
* bit are not modeled — we treat KSEG0/KUSEG as always cached and KSEG1 as always
* uncached. This is exact except during the brief pre-cache-enable boot window and
* inside the cache-flush/isolation routine.
*
* State is SHARED by both backends (interp per-instruction; compiled per-cache-line
* leader) so the cache evolves identically to Beetle for the same fetch stream.
*/
#include "cpu_state.h"
#include <stdint.h>
#include <stdlib.h>
extern void psx_advance_cycles(uint32_t cycles);
/* Opt-in gate (default OFF). The I-cache fetch cost must be charged by BOTH backends
* or NEITHER — charging it only in the interp (Stage 1) while the compiled path does
* not would make the two backends disagree on fetch cost in mixed compiled/interp
* execution (e.g. Tomba 2 overlays), forking timing. So until the compiled emitters
* also charge it (Stage 2), this is enabled only for measurement (PSX_ICACHE=1, used
* with PSX_FORCE_INTERP=1 so ALL code is interp'd → consistent). Stage 2 flips the
* default on once both backends charge it. */
int psx_icache_enabled(void) {
static int s = -1;
if (s < 0) { const char* e = getenv("PSX_ICACHE"); s = (e && e[0] && e[0] != '0'); }
return s;
}
/* Per-word tag+validity, mirroring Beetle ICache[idx].TV. Init to a value that can
* never equal an aligned fetch address (aligned addrs have bits 0-1 = 0), so every
* line starts cold (miss). */
static uint32_t s_icache_tv[1024];
void psx_icache_reset(void) {
for (int i = 0; i < 1024; i++) s_icache_tv[i] = 0x1u; /* bit0 set => never matches */
}
/* Charge the fetch cost for the instruction at `addr` and update the cache tags.
* `addr` is the runtime guest virtual PC (aligned). Gated on PSX_ENABLE_BLOCK_CYCLES. */
void psx_icache_fetch(CPUState* cpu, uint32_t addr) {
#ifdef PSX_ENABLE_BLOCK_CYCLES
if (!psx_icache_enabled()) return;
uint32_t idx = (addr & 0xFFCu) >> 2;
if (s_icache_tv[idx] == addr) return; /* HIT: +0, no give-back clear */
/* MISS — clear the pending load give-back (Beetle cpu.cpp:542-543). */
cpu->read_absorb[cpu->read_absorb_which] = 0u;
cpu->read_absorb_which = 0u;
if (addr >= 0xA0000000u) { /* KSEG1 / uncached (BIOS ROM) */
psx_advance_cycles(4u);
return;
}
/* Cached refill (KSEG0/KUSEG). Mark the whole 4-word line invalid, then validate
* + count the words from the missing one to the line end. */
uint32_t line = addr & 0xFFFFFFF0u;
uint32_t bidx = (addr & 0xFF0u) >> 2; /* first word index of the line */
s_icache_tv[bidx + 0] = line | 0x0u | 0x2u;
s_icache_tv[bidx + 1] = line | 0x4u | 0x2u;
s_icache_tv[bidx + 2] = line | 0x8u | 0x2u;
s_icache_tv[bidx + 3] = line | 0xCu | 0x2u;
uint32_t cost = 3u;
for (uint32_t i = (addr & 0xCu) >> 2; i < 4u; i++) {
s_icache_tv[bidx + i] &= ~0x2u; /* valid: TV == that word's addr */
cost++;
}
psx_advance_cycles(cost);
#else
(void)cpu; (void)addr;
#endif
}
+25
View File
@@ -109,6 +109,31 @@ load2/load_use/div/div_spaced/mult/gte_rtps/gte_nclip/gte_read_use/ld_div all ma
the oracle, so the interpreter's per-instruction interlock model is MEASURED equal
to the compiled backend and to Beetle, not merely shared-by-construction.
## I-cache fetch (icache_miss loop + PSX_ICACHE)
The `icache_miss` loop forces an I-cache miss every iteration: its top and a victim
block 0x1000 bytes away map to the SAME direct-mapped line (4 KB / 256-line cache,
index = addr bits 4-11) with different tags, so each fetch evicts the other. (Every
other loop is small enough to be all-hits after warm-up — fetch cost 0.)
The faithful R3000A I-cache fetch model lives in `runtime/src/psx_icache.c` (HIT +0,
KSEG1/uncached +4, cached miss +3 + refill from the missing word to the line end —
transcribed from Beetle ReadInstruction). It is **opt-in via `PSX_ICACHE=1`** (default
OFF) until BOTH backends charge it: charging it only in the interp while the compiled
path does not would make the two backends disagree on fetch cost in mixed execution.
Measure the interp path with both envs:
```powershell
$env:PSX_FORCE_INTERP='1'; $env:PSX_ICACHE='1'; Start-Process psx-cyctest.exe -ArgumentList ...
```
**icache_miss == Beetle EXACT (2026-06-27): native-interp +14 == Beetle +14** (per-iter
17). The hit path (the other 12 loops) is unchanged at +0 fetch. So the I-cache
hit AND refill-miss costs are MEASURED equal to the oracle on the interp path.
STAGE 2 (pending): charge the same model in the compiled emitters (per cache-line
leader) and flip PSX_ICACHE on by default; validate ruler #1's cold first-hit spike
(Beetle 84/77 vs steady 56) on the compiled path.
## Beetle ORACLE results (2026-06-26 — the HW cost targets)
Per-iteration cycle delta, and component cost (minus baseline=3):
+20
View File
@@ -184,6 +184,26 @@ def build():
# pending load give-back; native==Beetle means no divergence to fix.
inner_loop("ld_div", [lw("$t4", 0, "$t5"), divu("$t2", "$t3"), mflo("$t7")])
# icache_miss: force an I-cache miss every iteration via 4KB aliasing. The loop
# top and a "victim" block 0x1000 bytes away map to the SAME direct-mapped line
# (4 KB / 256-line cache, index = addr bits 4-11) with DIFFERENT tags, so each
# fetch evicts the other -> a refill miss every iteration. Isolates the I-cache
# refill cost; every other loop is small enough to be all-hits after warm-up.
a.emit(li("$t0", ITER))
ic_top = a.here()
anchors["icache_miss"] = ic_top
a.label("icache_miss_top")
a.emit(addiu("$t0", "$t0", -1)) # ic_top + 0
victim_addr = ic_top + 0x1000 # aliases ic_top's line (only bit 12 differs)
a.emit(j(victim_addr >> 2)) # ic_top + 4: jump to the aliased victim
a.emit(nop()) # ic_top + 8: delay slot
while a.here() < victim_addr: # pad (not executed; the j skips it)
a.emit(nop())
# victim @ ic_top+0x1000: loop back to ic_top (or fall through on exit)
voff = (a.labels["icache_miss_top"] - (a.here() + 4)) >> 2
a.emit(bne("$t0", "$zero", voff)) # victim + 0
a.emit(nop()) # victim + 4: delay slot
# loop outer forever
o = a.labels["outer"]
here = a.here()