timing: I-cache fetch Stage 2 — compiled-path emit (both emitters) + default-on

Charge the faithful R3000A I-cache fetch cost in BOTH static emitters at each
cache-line leader, completing the model (interp-only since Stage 1). A leader is
a block leader / mid-block jump-table target (a possibly-cold line entry) OR a
16-byte-line start (addr&0xC==0); intra-line fall-through followers are provable
hits (+0) and emit nothing. Emitted BEFORE the per-instruction interlock/load so
a fetch miss clears the pending load give-back first (Beetle ReadInstruction
order).

- code_generator.cpp (game): emit_pre_icache at the 3 timing sites (normal,
  branch, delay slot); `addr` is already the KSEG0 runtime PC.
- full_function_emitter.cpp (BIOS): emit_icache_fetch at all 5 emit_insn_interlock
  sites (main loop + 4 orphaned delay slots), mapping the ROM/compile addr to the
  RUNTIME guest PC via relocate_ra (BIOS main stays KSEG1 0xBFC.. uncached;
  kernel Part 2 -> 0x500+, shell -> 0x80030000+). Same address value the interp
  passes for the same instruction, so the shared TV array stays consistent.
- psx_icache.c: flip psx_icache_enabled() DEFAULT ON (PSX_ICACHE=0 to disable).

Validated: ruler #2 compiled (4600) == Beetle (4382) EXACT on all 13 loops incl.
icache_miss +14 (was +0); ruler #1 steady delta 0 (56==56) with native cold-refill
spikes (77/84) now matching Beetle's range; Tomba 2 boots past the load wedge to
the intro FMV + PS BIOS logo, no freeze. Default-on path re-validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-27 17:59:13 -07:00
parent 53553ebe47
commit 0edb9355bb
4 changed files with 82 additions and 9 deletions
+24
View File
@@ -213,6 +213,30 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-06-27 (I-cache fetch — Stage 2 DONE: compiled-path emit + production default-on):**
Both static emitters now charge the I-cache fetch cost at each cache-line LEADER, BEFORE
the per-instruction interlock/load (Beetle ReadInstruction order, so a fetch miss clears
the pending load give-back first). Leader = a block leader / mid-block jump-table target
(any non-fall-through entry → possibly-cold line) OR a 16-byte-line start (addr&0xC==0);
intra-line fall-through followers are provably hits (the leader refilled the line to its
end) so they emit nothing (+0). code_generator (game): `addr` is already the KSEG0 runtime
PC. full_function_emitter (BIOS): the loop addr is the ROM/compile addr, mapped to the
RUNTIME guest PC via the existing `relocate_ra` (BIOS main stays in-place KSEG1 0xBFC..
uncached; kernel Part 2 → 0x500+, shell → 0x80030000+) so the shared TV array evolves
identically to the interp's cpu->pc and the KSEG1 (>=0xA0000000) uncached test sees the
true virtual address. The compiled path emits at the SAME address value the interp would
for the same instruction (they share s_icache_tv), so mixed compiled/interp stays
consistent. VALIDATED: ruler #2 COMPILED (port 4600, PSX_ICACHE=1) == Beetle (4382) EXACT
on all 13 loops incl. `icache_miss +14` (was +0 pre-Stage-2); ruler #1 [0x1C5C→0x1CA4]
steady delta 0 (56==56) AND native now produces the I-cache cold-refill spikes (77/84,
Beetle's range) that were absent before — exact per-hit magnitude varies run-to-run
because the two processes free-run (Rule 16), not a bug. Tomba 2 boots past the load wedge
→ intro FMV (jungle) + crisp PS BIOS logo, total_checks advancing, no freeze. Then flipped
`psx_icache_enabled()` DEFAULT ON (PSX_ICACHE=0 still disables for A/B) — re-validated the
default-on path (cyctest no-env == +14; Tomba 2 boots clean). psx_icache.c + both emitters.
NEXT axis: DMA cycle-steal / device-region MMIO load waits (SPU +36 etc.), currently
unmodeled. Eventually merge wt/tomba2-load-accuracy to master after cross-title regen+smoke.
- **2026-06-27 (I-cache fetch — MODEL built + interp-validated EXACT; Stage 1 of 2):**
New runtime/src/psx_icache.c: faithful direct-mapped (4 KB / 256-line) instruction-cache
fetch cost, transcribed from Beetle PS_CPU::ReadInstruction — HIT +0 (no give-back clear),
+23 -1
View File
@@ -1308,6 +1308,24 @@ std::string CodeGenerator::translate_basic_block(
psx_cyc_dep_res_mask(in));
ss << "#endif\n";
};
// I-cache FETCH cost (faithful R3000A), emitted BEFORE the per-instruction
// interlock/load — exactly like Beetle ReadInstruction precedes the base, and so a
// fetch MISS clears any pending load give-back before the next load arms one. Only
// emitted at cache-line LEADERS: a block leader / mid-block jump-table target (any
// address reachable other than by fall-through, i.e. a possibly-cold cache entry) OR
// a 16-byte-line start (addr&0xC==0, a sequential line crossing). Intra-line
// followers reached by fall-through are guaranteed hits — the leader's fetch
// refilled the line to its end — so they need no call (+0). Extra fetch points are
// harmless (a hit is +0); only UNDER-counting a cold entry would diverge, which the
// leader set prevents. The game runs at its KSEG0 load address, so `insn_addr` is
// already the runtime guest PC (matching the dirty-RAM interp's cpu->pc and Beetle).
auto emit_pre_icache = [&](uint32_t insn_addr, const std::string& indent) {
if (!(insn_addr == block.start_addr || (insn_addr & 0xCu) == 0 ||
extra_labels_.count(insn_addr))) return;
ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n";
ss << indent << fmt::format("psx_icache_fetch(cpu, 0x{:08X}u);\n", insn_addr);
ss << "#endif\n";
};
if (!cycle_per_insn && block_exec_cycles > 0) {
ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n";
ss << config_.indent << fmt::format("psx_advance_cycles({}u);\n",
@@ -1366,6 +1384,7 @@ std::string CodeGenerator::translate_basic_block(
}
if (!is_cf) {
if (cycle_per_insn) emit_pre_icache(addr, config_.indent);
if (cycle_per_insn) emit_pre_timing(instr, config_.indent);
ss << translate_instruction(addr, instr) << "\n";
} else {
@@ -1375,6 +1394,8 @@ std::string CodeGenerator::translate_basic_block(
// runs at the branch PC, THEN the delay slot's at PC+4. Emit the branch
// step FIRST (it is pure timing — does not touch GPR values, so it is
// safe before the branch-condition capture below).
if (cycle_per_insn)
emit_pre_icache(exit_branch_addr, config_.indent);
if (cycle_per_insn)
emit_pre_timing(block.exit_instr.instruction, config_.indent);
@@ -1406,7 +1427,8 @@ std::string CodeGenerator::translate_basic_block(
delay_saved_cond, cond);
}
ss << config_.indent << "/* delay slot (always executes) */\n";
// Delay slot's own §1+deps+DO_LDS, before its body (skipped if a load).
// Delay slot's own fetch + §1+deps+DO_LDS, before its body (skipped if a load).
if (cycle_per_insn) emit_pre_icache(delay_slot_addr, config_.indent);
if (cycle_per_insn) emit_pre_timing(delay_instr, config_.indent);
ss << translate_instruction(delay_slot_addr, delay_instr) << "\n";
}
+25
View File
@@ -388,6 +388,26 @@ bool FullFunctionEmitter::emit_function(
psx_cyc_dep_res_mask(w));
};
// I-cache FETCH cost (faithful R3000A), emitted BEFORE the per-instruction
// interlock/load — like Beetle ReadInstruction precedes the base, so a fetch MISS
// clears any pending load give-back before the next load arms one. Only emitted at
// cache-line LEADERS: a block leader (any branch/dispatch entry — a possibly-cold
// cache entry; cross-function targets are inserted into block_leaders above) OR a
// 16-byte-line start (addr&0xC==0, a sequential line crossing). Intra-line followers
// reached by fall-through are guaranteed hits (the leader refilled the line to its
// end) → no call (+0). `rom_addr` is the ROM/compile-time address; relocate_ra maps
// it to the RUNTIME guest PC the CPU actually fetches from (BIOS main stays in-place
// KSEG1 0xBFC..; relocated kernel Part 2 → 0x500+, shell → 0x80030000+), so the
// shared I-cache evolves identically to the dirty-RAM interp (cpu->pc) and Beetle —
// and the KSEG1 uncached test (>=0xA0000000) sees the true virtual address. The
// relocation preserves bits[3:0], so the line-leader test is space-independent.
auto emit_icache_fetch = [&](uint32_t rom_addr) {
if (!per_insn_cycles) return;
if (!(block_leaders.count(rom_addr) || (rom_addr & 0xCu) == 0)) return;
out += fmt::format("#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_icache_fetch(cpu, 0x{:08X}u);\n#endif\n",
relocate_ra(rom_addr));
};
for (auto it = addr_to_raw.begin(); it != addr_to_raw.end(); ++it) {
uint32_t addr = it->first;
uint32_t raw = it->second;
@@ -435,6 +455,7 @@ bool FullFunctionEmitter::emit_function(
// running cycle count is exact at MULT/DIV/MFLO/MFHI (and later GTE) for
// the completion-stall to absorb correctly. Orphaned (out-of-function)
// delay slots are inlined elsewhere and charged at those sites.
emit_icache_fetch(addr);
emit_insn_interlock(raw);
// Decode and translate.
@@ -561,6 +582,7 @@ bool FullFunctionEmitter::emit_function(
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_icache_fetch(ds_addr);
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
}
@@ -596,6 +618,7 @@ bool FullFunctionEmitter::emit_function(
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_icache_fetch(ds_addr);
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
}
@@ -620,6 +643,7 @@ bool FullFunctionEmitter::emit_function(
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_icache_fetch(ds_addr);
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
}
@@ -661,6 +685,7 @@ bool FullFunctionEmitter::emit_function(
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_icache_fetch(ds_addr);
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
}
+10 -8
View File
@@ -33,16 +33,18 @@
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. */
/* Gate (default ON as of Stage 2). The I-cache fetch cost is now charged by BOTH
* backends: the dirty-RAM interp (exec_one) AND both static emitters (code_generator
* game path + full_function_emitter BIOS path, at each cache-line leader, using the
* runtime guest PC). Because both charge it from a SHARED tag array, mixed
* compiled/interp execution stays consistent and the cache evolves like Beetle.
* Validated Stage 2 (2026-06-27): ruler #2 compiled == Beetle EXACT on all 13 loops
* incl. icache_miss +14; ruler #1 steady delta 0 with native cold-refill spikes
* (77/84) now matching Beetle's range; Tomba 2 FMV no-regression. Set PSX_ICACHE=0 to
* disable (e.g. to A/B the fetch-cost component). */
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'); }
if (s < 0) { const char* e = getenv("PSX_ICACHE"); s = (e == NULL || e[0] == '\0') ? 1 : (e[0] != '0'); }
return s;
}