EE-SRA 2 WS-B1: lazy-dirty pin seam machinery (inert; EE_PIN_LAZY_DIRTY=0)

Groundwork for pins-canonical-between-seams mode, all behind the compile-time
EE_PIN_LAZY_DIRTY flag (default 0 = write-through, byte-identical emission):

- armFlushEEGPRPins / armFlushEEClobberedPins: flush-ALL / flush-caller-saved
  pin mirrors to canonical memory. No dirty tracking — the lrps2 lesson says
  compile-time dirty subsets are unsound; seams flush everything.
- iFlushCall: FLUSH_ALL_X86-carrying modes (interpreter fallbacks, branch
  tails) flush all pins; lighter modes don't (their inline-fastmem hit paths
  make no call) — the caller-saved flush is instead emitted at every actual
  call site that pairs with armReloadEEClobberedPins (the reload reads
  canonical memory; unflushed it would resurrect stale values over dirty
  caller-saved pins). 20 sites paired across the rec + backpatch thunks.
- Dispatcher stubs: JITCompile / DispatcherEvent flush all pins before
  recRecompile / recEventTest (compile-time hooks and savestate/VM-exit read
  guest GPR memory); block-discard / page-reset flush the caller-saved set.
- mVU macro-mode COP2 bridge gains the matching flush-before
  (armEmitEEClobberedPinFlushForCOP2) around waitMTVU.
- iCOP2 vu0Sync sequences: flush hoisted before the FIRST call of each
  sequence, and the previously brace-less if/else bodies are braced — the
  flush must not run after the pins are already clobbered.

recompiler_tests 1112/1112 with the flag off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brian Degenhardt
2026-07-07 08:08:09 -07:00
co-authored by Claude Fable 5
parent fce6d7985f
commit 39ce31186d
10 changed files with 109 additions and 8 deletions
+6 -4
View File
@@ -145,10 +145,12 @@ void armBeginStackFrame(bool save_fpr);
void armEndStackFrame(bool save_fpr);
bool armIsCalleeSavedRegister(int reg);
// Emits the EE JIT's caller-saved pin reload (see kEEPinTable in
// iR5900-arm64.h). Out-of-line bridge for emission contexts that can't
// include the EE rec header — currently only mVU macro-mode emit bodies
// that emit C calls inline into EE blocks (mVUaddrFix's waitMTVU).
// Emits the EE JIT's caller-saved pin flush-before / reload-after (see
// kEEPinTable in iR5900-arm64.h). Out-of-line bridges for emission contexts
// that can't include the EE rec header — currently only mVU macro-mode emit
// bodies that emit C calls inline into EE blocks (mVUaddrFix's waitMTVU).
// The flush is a lazy-dirty-mode no-op (EE_PIN_LAZY_DIRTY).
void armEmitEEClobberedPinFlushForCOP2();
void armEmitEEClobberedPinReloadForCOP2();
vixl::aarch64::MemOperand armOffsetMemOperand(const vixl::aarch64::MemOperand& op, s64 offset);
+7
View File
@@ -140,10 +140,15 @@ void vtlb_DynBackpatchLoadStore(uptr code_address, u32 code_size, u32 guest_pc,
// events and cause cascading mid-block timing bugs. Matches the
// pattern at recVTLB-arm64.cpp:112+120.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
if (is_load)
{
armEmitCall((void*)vtlb_memRead128);
}
else
{
armEmitCall((void*)vtlb_memWrite128);
}
armReloadCycleDelta();
// preserve_most spares x9-x15 but never x0-x8 — restore the rung-3
// x4-x7 pins the call clobbered (pins are not allocator state, so
@@ -200,6 +205,7 @@ void vtlb_DynBackpatchLoadStore(uptr code_address, u32 code_size, u32 guest_pc,
armAsm->Mov(a64::w0, a64::w9);
// Spill/reload RECCYCLE — see 128-bit slow_path above for rationale.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
switch (size_in_bits)
{
case 8: armEmitCall((void*)vtlb_memRead<mem8_t>); break;
@@ -293,6 +299,7 @@ void vtlb_DynBackpatchLoadStore(uptr code_address, u32 code_size, u32 guest_pc,
// Spill/reload RECCYCLE — see 128-bit slow_path above for rationale.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
switch (size_in_bits)
{
case 8: armEmitCall((void*)vtlb_memWrite<mem8_t>); break;
+1
View File
@@ -325,6 +325,7 @@ void recMTC0()
case 25: // Performance counters
iFlushCall(FLUSH_INTERPRETER);
emitFlushBlockCycles();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)Interp::MTC0);
emitReloadCycle();
// MTC0 writes no guest GPRs; restore the caller-saved pins the
+14
View File
@@ -649,9 +649,16 @@ void cop2EmitConditionalSync(bool interlock, void (*finishFunc)())
// reschedule nextEventCycle.
armFlushCycleDelta();
// Lazy-dirty seam: flush BEFORE the first call of the sequence
// (the pins are clobbered from then on; pairs with the reload
// below). The iFlushCall above lacks FLUSH_ALL_X86, so the
// central hook didn't cover this.
armFlushEEClobberedPins();
armEmitCall((void*)vu0Sync);
if (finishFunc)
{
armEmitCall((void*)finishFunc);
}
armReloadCycleDelta();
// The sync callees write VU state, not EE GPRs; restore the
@@ -687,13 +694,20 @@ void cop2EmitConditionalSync(bool interlock, void (*finishFunc)())
// Flush + re-derive the cycle delta around the C call (see comment above).
armFlushCycleDelta();
// Lazy-dirty seam: flush before whichever call is emitted (pairs with the
// reload below; the lighter iFlushCall above didn't cover this).
armFlushEEClobberedPins();
if (needsSync)
{
// Non-interlocked catch-up: run a 16-cycle minimum to amortize the mVU
// dispatch envelope over small blocks (6dc5087cb). If the block also
// contains an interlocked op, fall back to the exact sync.
armEmitCall((void*)(s_nBlockInterlocked ? vu0Sync : vu0SyncRunAhead));
}
else
{
armEmitCall((void*)_vu0FinishMicro);
}
armReloadCycleDelta();
// See the interlock branch above — same caller-saved pin restore, same
+1
View File
@@ -293,6 +293,7 @@ static void recFPUCall(void (*func)())
}
}
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)func);
// FPU interpreter fallbacks touch fpuRegs only, never cpuRegs.GPR; restore
// the caller-saved pins the C call clobbered — the block continues.
+29 -2
View File
@@ -423,6 +423,7 @@ static const void* _DynGen_JITCompile()
// convention is "every C-call boundary syncs RECCYCLE both ways".
armFlushCycleDelta();
armFlushEEGPRPins(); // lazy-dirty seam: compile-time hooks read guest GPRs
armAsm->Ldr(RWARG1, armCpuRegMem(&cpuRegs.pc));
armEmitCall((void*)recRecompile);
@@ -444,6 +445,7 @@ static const void* _DynGen_DispatcherEvent()
// modifies cpuRegs.cycle (e.g. fast-forwarding to nextEventCycle) and
// reschedules nextEventCycle itself.
armFlushCycleDelta();
armFlushEEGPRPins(); // lazy-dirty seam: savestate save / VM exit read GPR memory
armEmitCall((void*)recEventTest);
armReloadCycleDelta();
// Event processing can rewrite every guest GPR (savestate load on the EE
@@ -511,6 +513,7 @@ static const void* _DynGen_EnterRecompiledCode()
static const void* _DynGen_DispatchBlockDiscard()
{
u8* retval = armGetCurrentCodePointer();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)dyna_block_discard);
// DispatcherReg's cache-hit path reloads nothing — restore the
// caller-saved pins the C call clobbered before re-entering blocks.
@@ -522,6 +525,7 @@ static const void* _DynGen_DispatchBlockDiscard()
static const void* _DynGen_DispatchPageReset()
{
u8* retval = armGetCurrentCodePointer();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)dyna_page_reset);
// See _DynGen_DispatchBlockDiscard.
armReloadEEClobberedPins();
@@ -631,6 +635,17 @@ void iFlushCall(int flushtype)
g_cpuFlushedCode = true;
}
// Lazy-dirty pin seam (EE-SRA 2 WS-B; no-op in write-through mode).
// GPR-reading callees (interpreter fallbacks, branch tails, event paths —
// the modes carrying FLUSH_ALL_X86) need every pin canonical in memory.
// Lighter modes (FLUSH_CONSTANT_REGS before inline fastmem) do NOT flush
// here — their hit paths make no call; the caller-saved flush is emitted
// at the actual call sites instead (paired with armReloadEEClobberedPins).
// Ordered after _flushConstRegs: a const-tracked pinned reg materializes
// through the pin first.
if (flushtype & FLUSH_ALL_X86)
armFlushEEGPRPins();
#if 0
// Disabled in x86 too (iR5900.cpp:1235-1242). Left here #if 0'd so the
// FLUSH_CAUSE / g_maySignalException mechanism stays a faithful mirror of
@@ -691,8 +706,14 @@ static u32 interpCallFlushPcBias()
return g_recompilingDelaySlot ? 4 : 0;
}
// Bridge for mVU macro-mode emit bodies (can't see kEEPinTable): emit the
// caller-saved pin reload after a C call emitted inline into an EE block.
// Bridges for mVU macro-mode emit bodies (can't see kEEPinTable): emit the
// caller-saved pin flush-before / reload-after around a C call emitted inline
// into an EE block. The flush is a lazy-dirty-mode no-op in write-through.
void armEmitEEClobberedPinFlushForCOP2()
{
armFlushEEClobberedPins();
}
void armEmitEEClobberedPinReloadForCOP2()
{
armReloadEEClobberedPins();
@@ -991,6 +1012,7 @@ void SetBranchReg()
// target and receives the translated paddr.
if (EmuConfig.Gamefixes.GoemonTlbHack)
{
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)vtlb_V2P);
// vtlb_V2P writes no guest GPRs but clobbers the caller-saved pins;
// the DispatcherReg jump below can cache-hit straight into a block.
@@ -1293,6 +1315,7 @@ void recompileNextInstruction(bool delayslot, bool swapped_delay_slot)
// Step 2: Emit call to snapshot pre-instruction state
armAsm->Mov(a64::w0, cpuRegs.code);
armAsm->Mov(a64::w1, pc - 4); // current instruction PC
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)verifySnapshotPre);
// The hook is GPR-read-only but clobbers the caller-saved pins,
// and the native codegen emitted next reads guest state through
@@ -1308,6 +1331,7 @@ void recompileNextInstruction(bool delayslot, bool swapped_delay_slot)
// Step 5: Emit call to verify against interpreter
armAsm->Mov(a64::w0, cpuRegs.code);
armAsm->Mov(a64::w1, pc - 4);
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)verifyCheckPost);
armReloadEEClobberedPins(); // see verifySnapshotPre above
}
@@ -2262,6 +2286,7 @@ static void recRecompile(const u32 startpc)
{
armFlushCycleDelta();
armAsm->Mov(RWARG1, startpc);
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)ee_divtrace_jit_block_hook);
// Read-only hook, but the C call clobbers the caller-saved pins and
// the block body it precedes reads guest state through them.
@@ -2333,6 +2358,7 @@ static void recRecompile(const u32 startpc)
{
// 0x33ad48 / 0x35060c are the return address of the function (0x356250)
// that populates the TLB cache.
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)GoemonPreloadTlb);
// TLB-cache population writes no guest GPRs; restore the
// caller-saved pins the C call clobbered before the block body.
@@ -2346,6 +2372,7 @@ static void recRecompile(const u32 startpc)
// 0x3563b8 is the start of the function that invalidates a TLB-cache entry;
// a0 holds the key. Guest state is memory-resident at the prologue.
armAsm->Ldr(RWARG1, armCpuRegMem(&cpuRegs.GPR.n.a0.UL[0]));
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)GoemonUnloadTlb);
armReloadEEClobberedPins(); // see GoemonPreloadTlb above
}
+39
View File
@@ -247,6 +247,45 @@ static __fi void armReloadEEClobberedPins()
}
}
// EE-SRA 2 WS-B: lazy-dirty pin mode. 0 = write-through (memory canonical
// after every guest write — the shipping default). 1 = pins canonical between
// seams: guest writes to pinned GPRs skip the canonical store, and every seam
// where C code (or a JIT-internal 128-bit/raw reader) could observe stale GPR
// memory restores canonicity first via the flush helpers below. A/B by
// building a second binary with -DEE_PIN_LAZY_DIRTY=1 (codegen_ab two-binary
// flow); no runtime toggle — mixing modes across blocks is unsound.
#ifndef EE_PIN_LAZY_DIRTY
#define EE_PIN_LAZY_DIRTY 0
#endif
// Write every pin mirror back to canonical memory. No dirty tracking — the
// lrps2 lesson says compile-time dirty subsets are unsound for runtime-path-
// dependent dirtiness, so seams flush ALL pins (9 Str to one hot line at
// already-expensive boundaries). No-op in write-through mode.
static __fi void armFlushEEGPRPins()
{
if (!EE_PIN_LAZY_DIRTY)
return;
for (const EEPinnedGPR& pin : kEEPinTable)
armAsm->Str(pin.host, armCpuRegMem(&cpuRegs.GPR.r[pin.gpr].UD[0]));
}
// Flush only the CALLER-saved pins. Required before any C call that is
// followed by armReloadEEClobberedPins: the reload reads canonical memory,
// which under lazy-dirty is stale until flushed — the pair would otherwise
// silently LOSE the in-register writes. (Callee-saved pins ride through the
// call in their registers, so they need neither.) No-op in write-through.
static __fi void armFlushEEClobberedPins()
{
if (!EE_PIN_LAZY_DIRTY)
return;
for (const EEPinnedGPR& pin : kEEPinTable)
{
if (!armIsCalleeSavedRegister(static_cast<int>(pin.host.GetCode())))
armAsm->Str(pin.host, armCpuRegMem(&cpuRegs.GPR.r[pin.gpr].UD[0]));
}
}
static __fi void armLoadEERegPtr(const vixl::aarch64::CPURegister& reg, const void* field)
{
// Pinned guest GPR: serve the read from the mirror register. The mirror
+1
View File
@@ -311,6 +311,7 @@ static void recVCallmsImpl(void (*func)())
armAsm->Add(RECCYCLE, RECCYCLE, cycles);
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)func);
armReloadCycleDelta();
// VCALLMS/VCALLMSR run VU0 micro (writes VU state, not EE GPRs); restore
+5 -2
View File
@@ -331,11 +331,14 @@ __fi void mVUaddrFix(mV, const a64::Register& gprReg)
// Accessing VU1 regs from VU0
if (THREAD_VU1)
{
// Need to wait for VU1 thread
armEmitCall((void*)mVU.waitMTVU);
// COP2 macro mode emits this inline into an EE block where the
// caller-saved EE pins (x12/x13) are live across the call; micro
// mode runs behind a C boundary whose call site reloads instead.
// Flush-before is a lazy-dirty no-op in write-through mode.
if (mVU.cop2)
armEmitEEClobberedPinFlushForCOP2();
// Need to wait for VU1 thread
armEmitCall((void*)mVU.waitMTVU);
if (mVU.cop2)
armEmitEEClobberedPinReloadForCOP2();
}
+6
View File
@@ -106,6 +106,7 @@ static void vtlbSoftmemRead(int addr_wreg, u32 bits, bool sign)
// this the JIT's pinned x25 stays stale and block-end cycle compare
// never trips on tight INTC polls.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
switch (bits)
{
case 8: armEmitCall((void*)vtlb_memRead<mem8_t>); break;
@@ -178,6 +179,7 @@ static void vtlbSoftmemWrite(int addr_wreg, int value_reg, u32 bits)
// any cycle-mutating handler reachable from MMIO must keep the JIT's
// pinned x25 coherent. See vtlbSoftmemRead for full rationale.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
switch (bits)
{
case 8: armEmitCall((void*)vtlb_memWrite<mem8_t>); break;
@@ -419,6 +421,7 @@ static bool recLoadConstPaddrMMIOShortcut(u32 bits, bool sign)
// path, including page-0F INTC_STAT → IntCHackCheck which mutates
// cpuRegs.cycle. See vtlbSoftmemRead for full rationale.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall(vmv.assumeHandlerGetRaw(szidx, false));
armReloadCycleDelta();
// Raw registered handlers are plain AAPCS (not preserve_most like the
@@ -596,6 +599,7 @@ static bool recStoreConstPaddrMMIOShortcut(u32 bits)
// RECCYCLE coherence — same rationale as recLoadConstPaddrMMIOShortcut.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall(vmv.assumeHandlerGetRaw(szidx, true));
armReloadCycleDelta();
// Caller-saved pin restore — same rationale as the read shortcut.
@@ -705,6 +709,7 @@ static void vtlbSoftmemRead128(int addr_wreg)
armAsm->Mov(a64::w0, a64::w9);
// See vtlbSoftmemRead for the RECCYCLE coherence rationale.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)vtlb_memRead128);
armReloadCycleDelta();
// x4-x7 pin restore — see vtlbSoftmemRead (q0 result untouched).
@@ -739,6 +744,7 @@ static void vtlbSoftmemWrite128(int addr_wreg)
armAsm->Mov(a64::w0, a64::w9);
// See vtlbSoftmemRead for the RECCYCLE coherence rationale.
armFlushCycleDelta();
armFlushEEClobberedPins(); // lazy-dirty seam: pairs with the reload below
armEmitCall((void*)vtlb_memWrite128);
armReloadCycleDelta();
// x4-x7 pin restore — see vtlbSoftmemRead.