arm64: divert stale block-link sites to the dispatcher, not JITCompile

Remove() stamped a removed block's entry with B JITCompile, and Link()
routed not-yet-compiled targets there too. JITCompile unconditionally
recompiles from cpuRegs.pc — so when a block was removed and later
recompiled at a new address, any orphaned link site still branching to
the old entry re-entered recRecompile on an already-compiled block:
fnptr assert on Devel, and on Release a spurious in-place recompile
that superseded live code and stranded other orphaned callers in the
stale pre-SMC compile (the NFS Carbon SLUS-21493 post-FMV deadlock,
localized by twindiff against the pre-transplant build).

Every link tail in both recs already stores the target guest pc before
its branch, so the correct stale-site policy is re-DISPATCH, never
re-COMPILE: divert to DispatcherReg, which routes through the recLUT
(the single SMC-invalidation rewrite point) to the current code, or to
compilation via the LUT's own JITCompile slot when genuinely
uncompiled. Both AetherSX2's Remove() and the pre-transplant linker
enforce this same fallback-to-dispatcher policy; the recRecompile
fnptr assert becomes a true invariant again.

Zero hot-path cost: the dispatcher hop is paid only by a cold first
execution of an unresolved link (repatched direct by New()) or by a
stale site after SMC churn. Remove() keeps its signal-safety contract
(single atomic 4-byte B patch, no link-map access).

recompiler_tests: 1415/1415. Carbon twincompare free-runs 200 frames
where it previously aborted on the recRecompile assert.
This commit is contained in:
Brian Degenhardt
2026-07-24 15:30:32 -07:00
parent eb61611b9d
commit 9eb93d4dcd
4 changed files with 159 additions and 19 deletions
+45 -14
View File
@@ -12,12 +12,24 @@
// - Link()/New() touch the multimap, but they only run from the
// compile path (single-threaded, never from a signal).
// - Remove() does NOT walk the link map. Instead it overwrites the
// first 4 bytes of each removed block with `B JITCompile`, so any
// stale link still resolves correctly via the dispatcher (which
// can re-patch the link to the freshly compiled target on its next
// dispatch). Block memory isn't reclaimed until a full reset, so
// this 4-byte rewrite always lands on memory the recompiler still
// owns.
// first 4 bytes of each removed block with `B DispatcherReg`, so any
// stale link re-dispatches through the recLUT. Block memory isn't
// reclaimed until a full reset, so this 4-byte rewrite always lands
// on memory the recompiler still owns.
//
// Stale sites divert to the DISPATCHER, never to JITCompile. Every link
// tail stores the target guest pc into cpuRegs.pc before its branch (both
// recs, all forms — see SetBranchImm / psxSetBranchImm), so a diverted
// site re-dispatches from a pc that is already correct; the recLUT (the
// single SMC-invalidation rewrite point) then routes it to the current
// code, or through JITCompile if the target is genuinely uncompiled.
// Routing stale sites at JITCompile directly instead re-enters
// recRecompile on an already-compiled block whenever the target has been
// removed-and-recompiled — tripping the fnptr assert on Devel, and on
// Release spuriously superseding live code in place, which strands
// orphaned callers in the stale pre-SMC compile (the NFS Carbon
// SLUS-21493 block-cache corruption). AetherSX2's Remove() and the
// pre-transplant linker enforce the same fallback-to-dispatcher policy.
//
// The patch site for each link is the address of a single B instruction
// emitted by SetBranchImm (see iR5900-arm64.cpp). Aligned 32-bit stores
@@ -77,6 +89,16 @@ protected:
BaseBlockArray blocks;
linkmap_t links;
uptr jitcompile = 0;
uptr dispatcher = 0;
// Where a stale site (unresolved Link(), Remove() entry stamp) diverts.
// The dispatcher when one is registered — re-dispatch from the pc the
// site's own tail stored — with a JITCompile fallback for direct
// instantiation (unit tests) that never registers one.
__fi uptr StaleDispatchTarget() const
{
return dispatcher ? dispatcher : jitcompile;
}
// The block currently being compiled, published by New(). Link() stamps
// it onto every site it records.
@@ -203,6 +225,11 @@ public:
jitcompile = reinterpret_cast<uptr>(recompiler_);
}
void SetDispatcher(const void* dispatcher_)
{
dispatcher = reinterpret_cast<uptr>(dispatcher_);
}
// Register a link site that wants to branch directly to the block at
// `pc`. Patches immediately if a block already exists; otherwise
// records the site so New(pc, ...) can patch it later. `call` sites
@@ -215,7 +242,7 @@ public:
BASEBLOCKEX* target = Get(pc);
const uptr target_addr = (target && target->startpc == pc)
? target->fnptr : jitcompile;
? target->fnptr : StaleDispatchTarget();
// No flush: the site is in the block being emitted right now, and
// armEndBlock() range-flushes that buffer. See PatchWord.
PatchWord(reinterpret_cast<uptr>(patch_site),
@@ -337,12 +364,16 @@ public:
}
// Signal-safe: writes a redirect stub at each removed block's entry
// point so any stale link still resolves through JITCompile, then
// erases from the flat sorted array. Does NOT touch the link map —
// mutating an STL container here is not signal-safe. The entries this
// strands are reaped by the next New() for their destination PC, which
// sees the owner is gone; until then the redirect stub keeps any site
// still pointing at this block correct.
// point so any stale link re-dispatches through the recLUT (see the
// stale-dispatch policy note in the file header — the diverting site's
// tail already stored the correct pc, so DispatcherReg routes it to the
// current code; JITCompile here would re-enter recRecompile on a
// removed-and-recompiled block), then erases from the flat sorted
// array. Does NOT touch the link map — mutating an STL container here
// is not signal-safe. The entries this strands are reaped by the next
// New() for their destination PC, which sees the owner is gone; until
// then the redirect stub keeps any site still pointing at this block
// correct.
//
// SL-1: a resident self-loop's back-edge is an internal B to the loop-top
// (past the entry redirect), so it gets its own atomic repoint — to the
@@ -358,7 +389,7 @@ public:
for (int i = first; i <= last; ++i)
{
const uptr site = blocks[i].fnptr;
PatchAtomic(site, EncodeB(site, jitcompile));
PatchAtomic(site, EncodeB(site, StaleDispatchTarget()));
if (blocks[i].backedge_site)
PatchAtomic(blocks[i].backedge_site,
+6 -3
View File
@@ -211,10 +211,13 @@ static void _DynGen_Dispatchers()
iopEnterRecompiledCode = _DynGen_EnterRecompiledCode();
iopUnmappedRecLUTPage = _DynGen_UnmappedRecLUTPage();
// Block linker needs iopJITCompile so it can route stale / not-yet-
// compiled link sites through the dispatcher path. Mirrors EE rec
// at iR5900-arm64.cpp:674 and x86 IOP rec at iR3000A.cpp:257.
// Block linker: stale / not-yet-compiled link sites divert to
// iopDispatcherReg — re-dispatch from the pc the site's tail already
// stored (psxSetBranchImm and the fallthrough tail both Str psxRegs.pc
// before the link site), never a direct re-compile. See the
// stale-dispatch policy in BaseblockEx-arm64.h; mirrors the EE rec.
recBlocks.SetJITCompile(iopJITCompile);
recBlocks.SetDispatcher(iopDispatcherReg);
Perf::any.Register(start, static_cast<u32>(armGetCurrentCodePointer() - start), "IOP Dispatcher");
}
+6 -2
View File
@@ -629,9 +629,13 @@ static void _DynGen_Dispatchers()
DispatchPageReset = _DynGen_DispatchPageReset();
UnmappedRecLUTPage = _DynGen_UnmappedRecLUTPage();
// Block linker needs JITCompile so it can route stale / not-yet-compiled
// link sites through the dispatcher path.
// Block linker: stale / not-yet-compiled link sites divert to
// DispatcherReg — re-dispatch from the pc the site's tail already
// stored, never a direct re-compile (see the stale-dispatch policy in
// BaseblockEx-arm64.h). JITCompile is the direct-instantiation
// fallback and stays registered for the unit tests' sake.
recBlocks.SetJITCompile(JITCompile);
recBlocks.SetDispatcher(DispatcherReg);
Perf::any.Register(start, static_cast<u32>(armGetCurrentCodePointer() - start), "EE Dispatcher");
}
@@ -239,6 +239,108 @@ TEST(Arm64BaseBlocksLink, MapStaysBoundedUnderOwnerChurn)
EXPECT_EQ(code[0], first_word);
}
// ---------------------------------------------------------------------------
// Stale-dispatch policy. Every link tail stores the target guest pc into
// cpuRegs.pc before its branch, so a site that goes stale needs re-DISPATCH
// (LUT lookup on the already-correct pc), never re-COMPILE. Routing stale
// sites at JITCompile instead re-enters recRecompile on an already-compiled
// block — the NFS Carbon (SLUS-21493) block-cache corruption: the spurious
// recompile supersedes live code in place, leaving orphaned callers running
// the stale pre-SMC compile.
// ---------------------------------------------------------------------------
TEST(Arm64BaseBlocksLink, RemoveStampsRedirectToDispatcherNotRecompiler)
{
alignas(64) static u32 code[64];
std::memset(code, 0, sizeof(code));
Arm64BaseBlocks bb;
u32* const jitcompile = &code[0];
u32* const dispatcher = &code[4];
bb.SetJITCompile(jitcompile);
bb.SetDispatcher(dispatcher);
constexpr u32 kPc = 0x3000;
u32* const site = &code[8];
u32* const entry1 = &code[16];
bb.Link(kPc, site);
bb.New(kPc, reinterpret_cast<uptr>(entry1));
const int idx = bb.Index(kPc);
ASSERT_GE(idx, 0);
bb.Remove(idx, idx);
// The dead entry's redirect must target the dispatcher: a stale caller
// landing here re-dispatches from the pc its own tail already stored.
EXPECT_EQ(OpcodeBits(*entry1), kOpcB);
EXPECT_EQ(DecodeImm26Bytes(*entry1),
reinterpret_cast<intptr_t>(dispatcher) - reinterpret_cast<intptr_t>(entry1));
// Recompile at a new address: the old entry keeps diverting to the
// dispatcher, which routes the stale caller into the new code via the
// LUT — recRecompile is never re-entered on the compiled block.
u32* const entry2 = &code[32];
bb.New(kPc, reinterpret_cast<uptr>(entry2));
EXPECT_EQ(DecodeImm26Bytes(*entry1),
reinterpret_cast<intptr_t>(dispatcher) - reinterpret_cast<intptr_t>(entry1));
EXPECT_EQ(bb.Get(kPc)->fnptr, reinterpret_cast<uptr>(entry2));
}
TEST(Arm64BaseBlocksLink, UnresolvedLinkFallsBackToDispatcher)
{
alignas(64) static u32 code[64];
std::memset(code, 0, sizeof(code));
Arm64BaseBlocks bb;
u32* const jitcompile = &code[0];
u32* const dispatcher = &code[4];
bb.SetJITCompile(jitcompile);
bb.SetDispatcher(dispatcher);
constexpr u32 kPc = 0x3000;
u32* const site_b = &code[8];
u32* const site_bl = &code[9];
// Not-yet-compiled target: both forms route through the dispatcher (one
// extra hop on the cold first execution; the LUT slot still holds
// JITCompile, so compilation is reached through it with a correct pc).
bb.Link(kPc, site_b);
bb.Link(kPc, site_bl, /*call=*/true);
EXPECT_EQ(OpcodeBits(*site_b), kOpcB);
EXPECT_EQ(OpcodeBits(*site_bl), kOpcBL);
EXPECT_EQ(DecodeImm26Bytes(*site_b),
reinterpret_cast<intptr_t>(dispatcher) - reinterpret_cast<intptr_t>(site_b));
EXPECT_EQ(DecodeImm26Bytes(*site_bl),
reinterpret_cast<intptr_t>(dispatcher) - reinterpret_cast<intptr_t>(site_bl));
}
TEST(Arm64BaseBlocksLink, NoDispatcherRegisteredKeepsJITCompileFallback)
{
// Direct-instantiation compatibility: with no dispatcher registered the
// class behaves exactly as before (fallback = JITCompile).
alignas(64) static u32 code[64];
std::memset(code, 0, sizeof(code));
Arm64BaseBlocks bb;
bb.SetJITCompile(&code[0]);
constexpr u32 kPc = 0x3000;
u32* const site = &code[8];
u32* const entry = &code[16];
bb.Link(kPc, site);
EXPECT_EQ(DecodeImm26Bytes(*site),
reinterpret_cast<intptr_t>(&code[0]) - reinterpret_cast<intptr_t>(site));
bb.New(kPc, reinterpret_cast<uptr>(entry));
const int idx = bb.Index(kPc);
ASSERT_GE(idx, 0);
bb.Remove(idx, idx);
EXPECT_EQ(DecodeImm26Bytes(*entry),
reinterpret_cast<intptr_t>(&code[0]) - reinterpret_cast<intptr_t>(entry));
}
TEST(Arm64BaseBlocksLink, BlFormSurvivesPruning)
{
alignas(64) static u32 code[64];