diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index ff5b3cd0..7ee560a1 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -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 diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index cd52a192..6a2fe26b 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -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 diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index eef3670f..c93e7c18 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -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 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 2b27aece..cba802b2 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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), diff --git a/runtime/src/psx_icache.c b/runtime/src/psx_icache.c new file mode 100644 index 00000000..9f3fc7e5 --- /dev/null +++ b/runtime/src/psx_icache.c @@ -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 +#include + +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 +} diff --git a/tools/cycle_testrom/README.md b/tools/cycle_testrom/README.md index 3da01ad7..d5424521 100644 --- a/tools/cycle_testrom/README.md +++ b/tools/cycle_testrom/README.md @@ -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): diff --git a/tools/cycle_testrom/gen_testrom.py b/tools/cycle_testrom/gen_testrom.py index 25fe793f..dcb727b8 100644 --- a/tools/cycle_testrom/gen_testrom.py +++ b/tools/cycle_testrom/gen_testrom.py @@ -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()