Skip to content

ARM64EC: Raise guest exceptions from JIT code without a host trap - #6034

Closed
tarikbc wants to merge 1 commit into
FEX-Emu:mainfrom
tarikbc:arm64ec-guest-signal-handler
Closed

tarikbc wants to merge 1 commit into
FEX-Emu:mainfrom
tarikbc:arm64ec-guest-signal-handler

Conversation

@tarikbc

@tarikbc tarikbc commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

When JIT code raises a guest exception (ud2, int3, int 0x2d or 0x29, a trap-flag step, a privileged instruction or an execute fault), it fills in SynchronousFaultData and branches to one of the GuestSignal stubs in the dispatcher. The stub spills the static registers and then traps on the host with hlt, brk or a null load. On ARM64EC that trap goes through Wine's signal handler and a first pass through KiUserExceptionDispatcher, and all that pass does is call RethrowGuestException, which rebuilds the guest context from the spilled state and raises it again with NtRaiseException.

This adds an optional Pointers.GuestSignalHandler. When it's set, the stubs branch to it after spilling instead of trapping. ARM64EC points it at HandleGuestSignal, which builds the guest context from the spilled state and raises the exception the same way RethrowGuestException does. The part both of them share is now RaiseGuestException.

HandleGuestSignal passes along the exception code the host trap would have produced: illegal instruction for hlt, breakpoint for brk and access violation for the null load. HandleGuestException keeps that code for execute faults, so it matters there. Frontends that don't set the pointer (Linux and WOW64) only get one extra load and branch in the stubs.

I tested it on an AYN Thor (Snapdragon 8 Gen 2) with GameNative and Proton 11 ARM64EC. The test is a small x64 program that records what a vectored exception handler sees for ud2, int3, a trap-flag step, hlt and a call into a non-executable page, and then times 20,000 ud2 and 20,000 int3 round trips.

On main (dab12ae), the records are the same with and without this change: code, flags, parameters, address and context registers. Each exception takes about 100 µs either way, though, because with this Wine NtContinue makes a wineserver round trip and that is most of the cost.

The FEX build I use for Age of Empires IV is based on 7d3090f and has a patch that skips that round trip, which Wine master no longer makes. With this change on top, the records still match, and an exception takes 0.86 µs instead of 1.82 µs for ud2 and 0.86 µs instead of 1.69 µs for int3. That game's anti-tamper raises around 44,000 illegal-instruction exceptions per second, and an earlier version of this change took its skirmish benchmark from 56.4 to 57.6 FPS.

Jumping straight into KiUserExceptionDispatcher instead of going through NtRaiseException would save a bit more, but then a debugger wouldn't see these first-chance exceptions, so I left that out.

The GuestSignal_* stubs now branch to an optional frontend handler
(Pointers.GuestSignalHandler) after spilling the static registers,
instead of trapping. ARM64EC sets it to HandleGuestSignal, which raises
the guest exception directly rather than through a host signal, its
dispatch in ntdll and RethrowGuestException. The shared part of
RethrowGuestException moves into RaiseGuestException.
@Sonicadvance1

Copy link
Copy Markdown
Member

Good shout, I was wondering if double faulting was going to cause a performance problem in the future, but I didn't have a real game that abused it to know.

This isn't up to our regular standards so I'm going to implement this myself and also hammer it out for wow64 and Linux and make a microbench for it.

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