perf(fpx): fuse single-precision FMA, FRECPS and FRSQRTS on x86-64-v3 #2383
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2383
Loading…
Reference in a new issue
No description provided.
Delete branch "perf/fpx-fused-fma32"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
On x86-64-v3 builds the single-precision FMADD/FMLA, FRECPS and FRSQRTS fast paths still compute in binary64 and round twice, although the CPU has a hardware FMA. One fused instruction gives the same results and makes FMA-bound guest code 22 to 45% faster.
What changed
suyu's runtime header uses
__builtin_fmafinrecomp_fpx_fma32,recomp_fpx_recps32andrecomp_fpx_rsqrts32only whenRECOMP_FPX_HOST == 1(an AArch64 host). On x86-64-v3, whereRECOMP_FPX_FMA64is already set from__FMA__, they took the binary64 form with a midpoint test, and fell back to the exact body on every binary32 midpoint.New
nx2/suyu_rt_fixes.pywrapsa64_rtgen.runtime_h, aslift_fixesdoes for the lifter, and changes those three guards toRECOMP_FPX_HOST == 1 || RECOMP_FPX_FMA64. Nothing underthird_party/is edited. Each substitution must match exactly once, so a refreshed a64-kit that moves these lines fails loudly.nx2/lift.pyandnx2/test_leaf.pyinstall it next tolift_fixes.Builds at x86-64-v2, baseline x86-64, MSVC and wasm are unchanged:
RECOMP_FPX_FMA64is 0 there. An existing game picks the change up on its next lift (a new file undernx2/changes the lift key).Measurements
llvm-mingw clang 23 from the v2.0.14 builder, at the build's lifted-code flags (
-march=x86-64-v3 -ffp-contract=off -fno-strict-aliasing -O2), headers from the real generators. Synthetic kernels shaped like the lifted call forms; thread CPU cycles per element (QueryThreadCycleTime), median of 31 samples, 7 interleaved before/after rounds:The new header emits
vfmadd/vfnmaddand nofmafcall.The game-wide gain depends on how much of a frame runs these instructions, which needs an in-game frame-time comparison. I have not run one.
How it was tested
tests/fpx_sticky_test.cgets afastmode: the inline fast paths againstrecomp_fpe_*, value and FPSR, under random FPCR/FPSR witha*bnear 1, 2 or 3 as in the Newton steps, plus three binary32 midpoints that must be kept when the build has FMA.tests/test_fpx_sticky.pyruns it with 8M cases in both the v3 and v2 builds: 0 mismatches. Withoutsuyu_rt_fixesinstalled the v3 run fails (the midpoints fall back).zgave 85k mismatches in 2M.python -I -B tests/test_fpx_sticky.pywithHOOKSHOT_MINGWat the builder's llvm-mingw: passes.python -I -B tests/run_all.pyon Windows: the same failing set as untouchedmainat32693f0, none new (the failures here come from tools missing on this PC).nx2/test_leaf.pyneeds the dump and was not run.Notes
The only behaviour change is that binary32 midpoints now stay on the fast path, with the same result. #2324 moves the
a64_rtgen.writecall innx2/lift.py; this change patches at import time, so it still applies, with at most a small conflict in the import block.Looks right to me. I went through it against the Arm semantics:
#ifguards now take the__builtin_fmafbranch that an AArch64 host already uses, so x86-64-v3 runs exactly the code path FPX1 was designed and proven for there. The keep conditions after it are unchanged: only a normal result (or a finite zero-product for FMADD) under an open gate (IXC already set, FPCR clean of RMode/DN/AHP/FZ16 and the inhibit bit) is kept, and FZ inputs andzare still screened. NaNs, infinities, tiny results and the FRECPS/FRSQRTS inf*0 case (NaN from the fma, never kept, exact body gives 2.0/1.5) all still fall back, so rounding mode, default-NaN, NaN propagation and flush-to-zero are all still the exact body's job.fmaf(-a, b, 3) * 0.5fmatches Arm's single rounding of(3 - a*b)/2whenever the halved value is normal, which is the only case kept; overflow before halving gives inf, so it falls back.RECOMP_FPX_FMA64is only 1 for HOST 1, or HOST 2 with__FMA__on GCC/clang, so x86-64-v2/baseline (host_march()on a CPU without FMA), MSVC and wasm keep the binary64 form. Since the game is built with the march of the PC it runs on, no FMA instruction can reach a CPU without one.test_fpx_sticky.py(with the newfastmode) and it passed on ubuntu-24.04 and windows-2022 (llvm-mingw). The midpoint cases I checked by hand (1+2^-12 squared, the FRECPS and FRSQRTS ones) really are binary32 midpoints, so the test does show the fused path is the one in use.make_game.lift_keyhashes every .py under nx2/, so the new file and the changed lift.py trigger a relift, and build.py's object key hashes the generated headers in gen/, soa64_suyu.hchanging recompiles the chunks. No stale lifted code.a64_rtgen.runtime_hworks becausewrite()looks upruntime_has a module global at call time, and the exactly-once substitution check is a good guard for a64-kit refreshes.CI: the failing checks are
test_vcore_fs_open/test_vcore_video_acquire(main fails them too right now) and the AppImage job timing out. None of that is from this PR.What's missing is evidence from the game: one relift and run, to check it boots and plays normally, plus a before/after frame time from somewhere FMA-heavy, would back up the perf claim. That's not blocking given how much the test covers, but it would be good to have before merge. Nit: expect a small import-block conflict with #2324, as you noted.
github-sync referenced this pull request2026-11-12 06:22:54 +00:00