mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 18:25:35 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user