Skip to content

[ARM64_DYNAREC] Reduce default safe-flag overhead with native flag carry - #4154

Open
jserv wants to merge 2 commits into
ptitSeb:mainfrom
jserv:arm64-loopflags-native-flag-carry
Open

[ARM64_DYNAREC] Reduce default safe-flag overhead with native flag carry#4154
jserv wants to merge 2 commits into
ptitSeb:mainfrom
jserv:arm64-loopflags-native-flag-carry

Conversation

@jserv

@jserv jserv commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Two ARM64 dynarec changes that cut the flag-handling overhead box64 pays under the default safe-flags policy (BOX64_DYNAREC_SAFEFLAGS=1), without changing correctness for the common case.

  1. TEST/CMP emission cleanup. Route TEST Eb,imm8 (0xF6 /0) through emit_test8c, store {x,w}ZR into the deferred op2 of emit_cmp{32,16,8}_0 instead of a zeroed scratch, and add ZF/PEND-only fast paths to emit_test{16,16c,8c} that use unshifted ANDS/ANDSw_mask. Static-only cleanup, runtime unchanged.

  2. Native flag carry across block boundaries. A flag producer that ends a block has X_PEND forced onto it, so the deferred op1/op2/res spill keeps full EFLAGS reconstructable for any successor or signal. When the producer's whole real flag need (gen_flags & X_ALL, restricted to ZF/SF/CF/OF) reaches its consumers purely through native ARM NZCV, as in the common loop-control CMP feeding a native Jcc, that spill is redundant for the branch. A guarded pass in updateNativeFlags (reusing getNativeFlagsUsed for the coverage proof) clears X_PEND for those producers, so emit_cmp/emit_test take the SET_DFNONE path. Controlled by a new BOX64_DYNAREC_LOOPFLAGS env var.

Result

On Ampere eMag 8180, benchfloat-O2 LINPACK, default safe flags, n=15 alternating, core-pinned, BOX64_DYNACACHE=0:

LOOPFLAGS median KFLOPS MAD range
0 (off) 637,780 230 636,542 .. 638,108
1 (on) 766,577 389 761,139 .. 767,342

+20.2%, the two distributions fully disjoint. The optimized figure matches SAFEFLAGS=0 (about 763k), so it recovers the entire default safe-flag gap on this workload.

The hot daxpy_ur loop-control CMP drops from 6 emitted ARM ops (STR op1/op2/df/res + SUBS) to 1 (SUBS); the JNZ still branches on native NZCV.

The TEST/CMP cleanup on its own is a static win only: benchfloat-O2 native instruction count 85,587 to 85,070 (517 fewer, about 68% of the 755 instruction safe vs SAFEFLAGS=0 gap), with no runtime change (the removed ops are cheap MOV/shift ALU the out-of-order core hides).

Correctness

All under default safe flags, on the same eMag host:

  • ctest 34/34, including longjumpInSignals
  • test05-O2 output bit-identical to baseline
  • self-checking flag test passes under SAFEFLAGS 0/1/2 and against the DYNAREC=0 interpreter
  • dynamic CMP/Jcc cosim (BOX64_DYNAREC_TEST): zero divergence, correct output
  • signal-time EFLAGS test: fault-time flags are byte-identical to baseline on every case

ZF/SF/CF/OF stay reconstructable at faults through adjust_arch's NZCV overlay (kept in need_nat_flags). Only PF/AF turn approximate across the block exit, which the dynarec already is for native flags (only the interpreter is exact at async fault points), and which the x86 ABI (arithmetic flags not preserved across calls) makes safe. A mid-block native CMP+Jcc already emits no deferred spill (gen_flags=X_ZF, no X_PEND); this brings the same treatment to the block-boundary case.

Notes

BOX64_DYNAREC_LOOPFLAGS defaults to 0. The safety argument for clearing the boundary spill rests on x86 ABI flag-death across calls, so it is kept opt-in pending broader real-application and Wine validation.

jserv added 2 commits July 30, 2026 09:01
Route TEST Eb,imm8 (0xF6 /0) through emit_test8c instead of materializing
the immediate into a scratch, store {x,w}ZR into the deferred op2 of
emit_cmp{32,16,8}_0 instead of a zeroed scratch, and add ZF/PEND-only
fast paths to emit_test{16,16c,8c} that use unshifted ANDS/ANDSw_mask
rather than shifting the byte or word result to bit 31 and back.

Measured on Ampere eMag 8180, default safe flags, static benchfloat-O2
native instruction count: 85,587 to 85,070, a drop of 517 (about 68% of
the 755 instruction gap between the safe default and SAFEFLAGS=0).
Runtime is unchanged (n=15 median 637.8k vs 637.7k KFLOPS): the removed
ops are cheap MOV and shift ALU that the out-of-order core hides behind
memory latency. This is a codegen cleanup, not a runtime win.

A self-checking flag test passes under SAFEFLAGS 0/1/2 and against the
DYNAREC=0 interpreter; a dump confirms the fast-path shape. Operands are
zero-extended by GETEW/GETGW/GETEB, so the unshifted ANDS yields correct
ZF, and the UpdateFlags recompute dynablock forces gen_flags=X_ALL, so
the ZF/PEND fast path cannot fire during signal reconstruction. The
byte-register emit_test8 form is deliberately left on the full path,
since adding the fast path there regressed the count.
A flag producer that ends a block has X_PEND forced onto it, so the
deferred op1/op2/res spill keeps full EFLAGS reconstructable for any
successor or signal. When the producer's whole real flag need (gen_flags
& X_ALL, restricted to ZF/SF/CF/OF) reaches its downstream consumers
purely through native ARM NZCV, as in the common loop-control CMP
feeding a native Jcc, that spill is redundant for the branch.

updateNativeFlags gains a guarded pass, reusing getNativeFlagsUsed for
the coverage proof, that clears X_PEND on such producers so emit_{cmp,
test} take the SET_DFNONE path. ZF/SF/CF/OF stay reconstructable at
faults through adjust_arch's NZCV overlay (kept in need_nat_flags); only
PF/AF turn approximate across the block exit, which the dynarec already
is for native flags and which the x86 ABI (arithmetic flags not
preserved across calls) makes safe. Controlled by BOX64_DYNAREC_LOOPFLAGS.

Effect: the daxpy_ur loop-control CMP drops from 6 emitted ops
(STR op1/op2/df/res + SUBS) to 1 (SUBS); the JNZ still branches on native
NZCV.

Measured on Ampere eMag 8180, benchfloat-O2, default safe flags, n=15
alternating core-pinned, BOX64_DYNACACHE=0:
  [off] median 637,780 KFLOPS (MAD 230, range 636,542..638,108)
  [on]  median 766,577 KFLOPS (MAD 389, range 761,139..767,342) +20.2%

The two distributions are fully disjoint. The optimized figure matches
SAFEFLAGS=0 (about 763k), so it recovers the entire default safe flag gap
on this workload.

A mid-block native CMP+Jcc already emits no deferred spill (gen_flags=X_ZF,
no X_PEND); this brings the same treatment to the block-boundary case.
@jserv jserv changed the title Arm64 loopflags native flag carry [ARM64_DYNAREC] Reduce default safe-flag overhead with native flag carry Jul 30, 2026
@ptitSeb

ptitSeb commented Jul 30, 2026

Copy link
Copy Markdown
Owner

So, can you split this PR in 2 (or 3 part), with the simple stuffs separated from the LOOP thingy (and the xZR stuff alone maybe, as this would be a 0 risk PR).

About the LOOP thing, I suppose you are using some form of Coding Agent? Because every explanation is ultra verbose and yet not really clear (sorry). If I understand correctly, the idea here is to ignore AF &PF flags when most other flags are already computed, as native, and X_PEND is also required: the idea is then to remove X_PEND and just ignore AF & PF to get faster loop (altho no loop check is involved here) by not storing Defered Flags informations. It's an interesting idea, but I want to point out that, for some opcode like COMI, PF is actually computed using native VF flags. Also, the name LOOP while it's not testing about loop is a bit surprising. Also also, there are documentation to change when you add a new env. var (docs/gen/usage.json then use gen.py to re-generates documentation)

Last point: I'm working a creating a new release of box64 (v0.4.4), so this PR will have to wait for the next developement cycle anyway.

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