Skip to content

JitArm64: implement the StaticRecomp fallback contract - #6

Open
dougchansan wants to merge 1 commit into
ExpansionPak:moderngekko-vendorfrom
dougchansan:jitarm64-staticrecomp-contract
Open

JitArm64: implement the StaticRecomp fallback contract#6
dougchansan wants to merge 1 commit into
ExpansionPak:moderngekko-vendorfrom
dougchansan:jitarm64-staticrecomp-contract

Conversation

@dougchansan

@dougchansan dougchansan commented Aug 4, 2026

Copy link
Copy Markdown

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, which means every arm64 "static recomp" measurement was really JitArm64.

The part that isn't obvious

Adding the hook alone is not enough. 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.

Behaviour change

Default on, matching x86-64 where StaticRecomp is already the default core. MODERNGEKKO_ARM64_STATICRECOMP=0 restores JitArm64. Flagging explicitly: this changes default behaviour for arm64 users of this branch.

Results

Apple Silicon (M5, macOS 26.1, Apple clang 17). Null backend, uncapped, 3 interleaved repeats with reverse-order control, from savestates. Two independent sessions; ratios reproduce to within ~2%.

game static fps jit ratio
Mario Kart: Double Dash GM4E01 0.9705 58.2 5.7059 5.88
Luigi's Mansion GLME01 1.2252 73.4 5.4671 4.46
Pokemon Colosseum GC6E01 1.3756 82.5 3.8508 2.80
Paper Mario TTYD G8ME01 1.8102 108.5 3.6882 2.04

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; each is one moderngekko-port build.

FP-unavailable exceptions run 0.0014–0.0023% of dispatches across 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 fake-precise figure.

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 and matches what was recorded on x86-64 before any of this work. #4 in this series explains and fixes the largest class (271 → 107). The remaining 107 are undiagnosed; none diverge on pc, and 2 touch MMIO, which a replay cannot reproduce by construction.

Depends on #3 to build.

Performance impact, stated plainly

This patch does not make static recompilation faster on arm64 — it makes it run at all. Before it, the module was entered once at boot and every subsequent instruction went through JitArm64, so the honest comparison is "static recomp executes / does not execute", not a percentage.

What it costs against the JIT is real and worth being explicit about. Uncapped, Null backend, MKDD race scene:

core speed fps
StaticRecomp (this patch) 0.9705x 58.2
JitArm64 5.7059x 342.0

So enabling static recompilation is a 5.9x slowdown versus the JIT on this host. That is the price of running an AOT-compiled native module instead of a dynamic recompiler, and it is why the flag exists.

With rendering and a real worst case it is tighter than the Null figures suggest:

scene backend speed fps
race, 1P Null 0.9705x 58.2
race, 1P Metal 0.8528x 51.1
race, split-screen Metal 0.6769x 40.6

Anyone adopting this on Apple Silicon should expect roughly 51 fps single-player and 41 fps split-screen, not 60.

The gain that is attributable to this branch as a whole: MKDD on its reference savestate reads 0.9273x and 0.9705x across two sessions against 0.7457x before the RecompCore rebase — +24% to +30% on the same scene and machine. That comes from the branch's sync and interrupt fixes; this patch is what lets them be measured on arm64 at all.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant