Skip to content

JitArm64: implement the StaticRecomp fallback contract - #6

Merged
siahisaforker merged 1 commit into
ExpansionPak:moderngekko-vendorfrom
dougchansan:jitarm64-staticrecomp-contract
Aug 10, 2026
Merged

JitArm64: implement the StaticRecomp fallback contract#6
siahisaforker merged 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 #7 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.
@dougchansan

Copy link
Copy Markdown
Author

Correction to the absolute figures here — the argument and the ratio hold, the absolute speeds do not.

Every macOS number I recorded before 2026-08-06 was taken with the laptop in Low Power Mode, a 2.02× CPU throttle I only found today by toggling it and watching a previously recorded figure reproduce to four decimal places. So the 0.9273x / 0.9705x / 0.7457x speeds in this description, and the 58.2 / 51.1 fps table, are all roughly half what the machine does.

Re-measured unthrottled, on AC, 3 repeats with a reversed-order control:

                    StaticRecomp        JitArm64        ratio
MKDD race, Null     1.8422x / 110.4     10.2791x        5.58x

Two things worth stating explicitly:

The 5.9× JIT gap in this description survives. Today it measures 5.58× — both cores were throttled equally, so the ratio was roughly right even while both absolutes were half. The trade-off this PR describes, and the reason the fallback contract matters, is unchanged.

The +24% "before vs after the rebase" claim also survives, for the same reason: same scene, same machine, same session.

I verified the JIT arm was genuinely the JIT rather than trusting the environment variable — the runner's shutdown line reports native=2719 dispatches against ~972,000,000 in a StaticRecomp run of the same length, i.e. the module is loaded at boot, entered a handful of times, then never again. Which is exactly the behaviour this PR exists to fix.

No change to the diff. Flagged so the numbers aren't read as the hardware's ceiling.

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.

2 participants