From 04a2b20f9a81025d38fdc6c97e392214bb81828a Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Tue, 4 Aug 2026 09:39:17 -1000 Subject: [PATCH] JitArm64: implement the StaticRecomp fallback contract Static recompilation was inert on Apple Silicon. Jit64 implements a two-part contract as the StaticRecomp fallback -- disable block linking, and call StaticRecompShouldYieldAt from the dispatcher -- and JitArm64 implemented neither. Linked arm64 blocks chained without returning to the dispatcher, so StaticRecompCore never regained control to ask whether the module covered the next address. The module was entered once at boot and never again, and every arm64 "static recomp" measurement was really JitArm64. Adding the hook alone is not enough, and this is the subtle part. JitArm64 keeps the PC in DISPATCHER_PC (W26) and only spills it to PPCSTATE at do_timing, which the yield exit bypasses -- so the core resumed from a stale ppcState.pc and dispatched the module at the wrong address. Jit64's identical hook is safe only because x86 keeps the PC in memory throughout. Hence the explicit STR before the exit branch. This is the one place the two JITs are not interchangeable. Default on, matching x86-64 where StaticRecomp is already the default core; MODERNGEKKO_ARM64_STATICRECOMP=0 restores JitArm64. Note this changes default behaviour for arm64 users of this branch. Measured on Apple Silicon (M5, macOS 26.1, Apple clang 17). Null backend, uncapped, 3 interleaved repeats with reverse-order control, from savestates. Two independent sessions; the ratios reproduce to within ~2%. game static fps jit ratio Mario Kart GM4E01 0.9705 58.2 5.7059 5.88 (race) Luigi's Mansion 1.2252 73.4 5.4671 4.46 (gameplay) Pokemon Colosseum 1.3756 82.5 3.8508 2.80 (title) Paper Mario TTYD 1.8102 108.5 3.6882 2.04 (title) Three of four clear realtime, all four with zero fallback steps -- the modules run the games rather than leaning on the JIT underneath. Luigi's Mansion, Colosseum and TTYD needed no per-game work at all; they are one `moderngekko-port build` each. FP-unavailable exceptions run 0.0014-0.0023% of dispatches on all four titles, against 12.4% before this branch's sync and interrupt fixes, so that repair is title-agnostic rather than MKDD-shaped. MKDD on its reference savestate reads 0.9273x and 0.9705x in two sessions against 0.7457x before the rebase -- same scene, same machine, so +24% to +30% rather than one precise figure. Note the host was never idle (foreign CPU 20-273% across arms); the tight standard deviations, not a quiet machine, are what make these trustworthy. Correctness. DolRecomp's suite passes 16/16 including pc_reference, fpscr and float_semantics. Lockstep on a 150 s race run reports divergences, and they are NOT introduced here: with MODERNGEKKO_NO_FALLBACK_JIT=1, where this patch is inert, the count is identical, matching what was recorded on x86-64 before any of this work. The lockstep-loop-boundary commit in this series explains and fixes the largest class of them (271 -> 107); the remaining 107 are undiagnosed, none diverge on pc, and 2 touch MMIO, which a replay cannot reproduce by construction. --- .../Core/PowerPC/JitArm64/JitArm64_Cache.cpp | 27 ++++++++++++++++- Source/Core/Core/PowerPC/JitArm64/JitAsm.cpp | 29 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/JitArm64/JitArm64_Cache.cpp b/Source/Core/Core/PowerPC/JitArm64/JitArm64_Cache.cpp index b59915ab1b..83c21ce0ea 100644 --- a/Source/Core/Core/PowerPC/JitArm64/JitArm64_Cache.cpp +++ b/Source/Core/Core/PowerPC/JitArm64/JitArm64_Cache.cpp @@ -3,6 +3,8 @@ #include "Core/PowerPC/JitArm64/Jit.h" +#include + #include #include #include @@ -79,9 +81,32 @@ void JitArm64::Init() GenerateAsmAndResetFreeMemoryRanges(); } + +// On by default, matching x86-64 where StaticRecomp is already the default CPU +// core. Set MODERNGEKKO_ARM64_STATICRECOMP=0 to run Dolphin's JitArm64 instead. +// Shared with JitAsm.cpp: both the block-linking policy and the dispatcher hook +// must agree, and reading the variable in each translation unit separately +// would let them disagree if one were ever changed alone. +bool JitArm64StaticRecompEnabled() +{ + static const bool enabled = [] { + const char* v = std::getenv("MODERNGEKKO_ARM64_STATICRECOMP"); + return !v || !*v || *v != '0'; + }(); + return enabled; +} + void JitArm64::SetBlockLinkingEnabled(bool enabled) { - jo.enableBlocklink = enabled && !SConfig::GetInstance().bJITNoBlockLinking; + // As the StaticRecomp fallback, blocks must not link to each other. Linked + // blocks chain without returning to the dispatcher, so control would never + // get back to StaticRecompCore to check whether the recompiled module covers + // the next address -- which is exactly why the module was entered once at + // boot and never again on arm64. Jit64 has always done this; JitArm64 did + // not, which made the whole static recompilation inert on Apple Silicon. + jo.enableBlocklink = + enabled && !SConfig::GetInstance().bJITNoBlockLinking && + !(IsStaticRecompFallback() && JitArm64StaticRecompEnabled()); } void JitArm64::SetOptimizationEnabled(bool enabled) diff --git a/Source/Core/Core/PowerPC/JitArm64/JitAsm.cpp b/Source/Core/Core/PowerPC/JitArm64/JitAsm.cpp index a65fd33a8f..c8def7a124 100644 --- a/Source/Core/Core/PowerPC/JitArm64/JitAsm.cpp +++ b/Source/Core/Core/PowerPC/JitArm64/JitAsm.cpp @@ -2,6 +2,9 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "Core/PowerPC/JitArm64/Jit.h" +#include "Core/PowerPC/StaticRecomp/StaticRecompCore.h" + +#include #include #include @@ -28,6 +31,11 @@ using namespace Arm64Gen; + +// Defined in JitArm64_Cache.cpp. The block-linking policy there and the +// dispatcher hook below must make the same decision, so they share one reader. +bool JitArm64StaticRecompEnabled(); + void JitArm64::GenerateAsm() { const Common::ScopedJITPageWriteAndNoExecute enable_jit_page_writes; @@ -96,6 +104,25 @@ void JitArm64::GenerateAsm() dispatcher_no_check = GetCodePtr(); + // Ask StaticRecompCore whether the recompiled module wants this address + // before falling into the JIT's own block lookup. A non-zero answer leaves + // the dispatcher so the module can run it. DISPATCHER_PC (W26) and PPC_REG + // (X29) are callee-saved under AAPCS64, so they survive the call. + FixupBranch static_recomp_exit; + const bool static_recomp_yield = IsStaticRecompFallback() && JitArm64StaticRecompEnabled(); + if (static_recomp_yield) + { + ABI_CallFunction(&StaticRecompShouldYieldAt, DISPATCHER_PC); + // Write the PC back before we can leave. JitArm64 keeps the PC in + // DISPATCHER_PC (W26) and only spills it to PPCSTATE at do_timing, which + // this exit path bypasses -- so StaticRecompCore was resuming from a stale + // ppcState.pc and dispatching the module at the wrong address. Jit64's + // identical hook is safe only because x86 keeps the PC in memory + // throughout. This is the one place the two JITs are not interchangeable. + STR(IndexType::Unsigned, DISPATCHER_PC, PPC_REG, PPCSTATE_OFF(pc)); + static_recomp_exit = CBNZ(ARM64Reg::W0); + } + bool assembly_dispatcher = true; if (assembly_dispatcher) @@ -225,6 +252,8 @@ void JitArm64::GenerateAsm() dispatcher_exit = GetCodePtr(); SetJumpTarget(exit); + if (static_recomp_yield) + SetJumpTarget(static_recomp_exit); // Reset the stack pointer, since the BLR optimization may have pushed things onto the stack // without popping them.