perf(lift): fast float regions, plain SSE checked once by MXCSR with the exact copy as fallback #2433
Open
jordanella wants to merge 4 commits from
jordanella/perf/hot-paths into main
pull from: jordanella/perf/hot-paths
merge into: MagicBeanDev:main
MagicBeanDev:main
MagicBeanDev:github/pr/2893
MagicBeanDev:github/pr/2890
MagicBeanDev:github/pr/2887
MagicBeanDev:github/pr/2878
MagicBeanDev:github/pr/2875
MagicBeanDev:github/pr/2874
MagicBeanDev:github/pr/2873
MagicBeanDev:github/pr/2871
MagicBeanDev:github/pr/2756
MagicBeanDev:github/pr/2897
MagicBeanDev:github/pr/2896
MagicBeanDev:github/pr/2718
MagicBeanDev:github/pr/2842
MagicBeanDev:github/pr/2691
MagicBeanDev:github/pr/2895
MagicBeanDev:github/pr/2743
MagicBeanDev:github/pr/2765
MagicBeanDev:github/pr/2708
MagicBeanDev:github/pr/2732
MagicBeanDev:github/pr/2755
MagicBeanDev:github/pr/2817
MagicBeanDev:github/pr/2876
MagicBeanDev:release-please--branches--main
MagicBeanDev:release-please--branches--main--release-notes
MagicBeanDev:github/pr/2778
MagicBeanDev:github/pr/2888
MagicBeanDev:github/pr/2866
MagicBeanDev:github/pr/2877
MagicBeanDev:github/pr/2800
MagicBeanDev:github/pr/2829
MagicBeanDev:github/pr/2850
MagicBeanDev:github/pr/2889
MagicBeanDev:github/pr/2882
MagicBeanDev:github/pr/2860
MagicBeanDev:github/pr/2849
MagicBeanDev:github/pr/2886
MagicBeanDev:github/pr/2794
MagicBeanDev:github/pr/2840
MagicBeanDev:github/pr/2830
MagicBeanDev:github/pr/2834
MagicBeanDev:github/pr/2719
MagicBeanDev:github/pr/2742
MagicBeanDev:github/pr/2741
MagicBeanDev:github/pr/2820
MagicBeanDev:github/pr/2771
MagicBeanDev:github/pr/2859
MagicBeanDev:github/pr/2858
MagicBeanDev:github/pr/2823
MagicBeanDev:github/pr/2757
MagicBeanDev:github/pr/2769
MagicBeanDev:github/pr/2844
MagicBeanDev:github/pr/2843
MagicBeanDev:github/pr/2673
MagicBeanDev:github/pr/2855
MagicBeanDev:github/pr/2739
MagicBeanDev:github/pr/2807
MagicBeanDev:github/pr/2851
MagicBeanDev:github/pr/2789
MagicBeanDev:github/pr/2814
MagicBeanDev:github/pr/2818
MagicBeanDev:github/pr/2774
MagicBeanDev:github/pr/2772
MagicBeanDev:github/pr/2750
MagicBeanDev:github/pr/2786
MagicBeanDev:github/pr/2785
MagicBeanDev:github/pr/2782
MagicBeanDev:github/pr/2779
MagicBeanDev:github/pr/2775
MagicBeanDev:github/pr/2706
MagicBeanDev:github/pr/2766
MagicBeanDev:github/pr/2747
MagicBeanDev:github/pr/2759
MagicBeanDev:github/pr/2745
MagicBeanDev:github/pr/2740
MagicBeanDev:github/pr/2733
MagicBeanDev:github/pr/2725
MagicBeanDev:github/pr/2727
MagicBeanDev:github/pr/2728
MagicBeanDev:github/pr/2729
MagicBeanDev:github/pr/2721
MagicBeanDev:github/pr/2722
MagicBeanDev:github/pr/2715
MagicBeanDev:github/pr/2714
MagicBeanDev:github/pr/2707
MagicBeanDev:github/pr/2703
MagicBeanDev:github/pr/2705
MagicBeanDev:github/pr/2697
MagicBeanDev:github/pr/2679
MagicBeanDev:github/pr/2676
MagicBeanDev:github/pr/2669
MagicBeanDev:docs/what-hookshot-connects-to
MagicBeanDev:fix/hdr-encode-test
MagicBeanDev:fix/main-tests
MagicBeanDev:fix/play-cache-files
MagicBeanDev:perf/game-barrier-order
MagicBeanDev:feat/heap-lazy
MagicBeanDev:feat/texture-detail
MagicBeanDev:perf/astc-opaque-bc1
MagicBeanDev:fix/fs-dir-lock
MagicBeanDev:fix/framegen-native-kept
MagicBeanDev:fix/update-no-downgrade
MagicBeanDev:port/k10-gcn-f16-mirrors
MagicBeanDev:fix/framegen-mult-cap
MagicBeanDev:chore/release-as-2.1.1
MagicBeanDev:perf/pcache-load-switch
MagicBeanDev:fix/prepare-progress-log
MagicBeanDev:fix/update-closes-stale-game
MagicBeanDev:fix/hdr-scaled-sampled
MagicBeanDev:perf/first-start-preset
MagicBeanDev:perf/zero-upload
MagicBeanDev:fix/vcore-escape-2s
MagicBeanDev:test/fake-gh-atomic
MagicBeanDev:perf/batch-idle-flush
MagicBeanDev:test/finish-stub-a
MagicBeanDev:chore/record-2.1.0
MagicBeanDev:fix/main-red-tests
MagicBeanDev:fix/test-display-startup-driver-cache
MagicBeanDev:feat/mesh-shader-mode
MagicBeanDev:fix/menu-probes-at-startup
MagicBeanDev:fix/fps-counter-classic
MagicBeanDev:fix/test-log-batch-flake
MagicBeanDev:fix/narrow-screen-hud
MagicBeanDev:ci/prereleases
MagicBeanDev:release/v2.0.15
MagicBeanDev:hotfix/v2.0.15
MagicBeanDev:fix/present-ahead-replay
MagicBeanDev:release/v2.0.13
MagicBeanDev:fix/bracket-paths-2013
MagicBeanDev:fix/hdr-overlays-2013
MagicBeanDev:fix/fps-counter-scale-2013
MagicBeanDev:fix/stick-seen-at-rest
MagicBeanDev:hotfix/menu-fits-screen
MagicBeanDev:fix/f1-always-opens-menu
MagicBeanDev:fix/ini-keep-lines
MagicBeanDev:claude/cranky-pike-7cf247
MagicBeanDev:perf/texture-answer-flat
MagicBeanDev:fix/stamp-test-modules
MagicBeanDev:fix/appimage-fonts
MagicBeanDev:fix/display-awake
MagicBeanDev:release/v2.0.12
MagicBeanDev:fix/discord-windows
MagicBeanDev:rc/v2.0.12-tests
MagicBeanDev:fix/dlss-keep-feature
MagicBeanDev:rc/v2.0.12
MagicBeanDev:button-icons
No reviewers
Labels
Clear labels
accessibility
Barrier affecting people with disabilities
autorelease: pending
autorelease: tagged
bug
Something isn't working
dependencies
Pull requests that update a dependency file
documentation
Improvements or additions to documentation
duplicate
This issue or pull request already exists
enhancement
New feature or request
github_actions
Pull requests that update GitHub Actions code
good first issue
Good for newcomers
help wanted
Extra attention is needed
invalid
This doesn't seem right
question
Further information is requested
testing wanted
wontfix
This will not be worked on
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
Milestone
Clear milestone
No items
No milestone
Assignees
Clear assignees
No assignees
2 participants
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2433
Loading…
Reference in a new issue
No description provided.
Delete branch "jordanella/perf/hot-paths"
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?
The frame is bound by the game's own job graph, and its biggest serial steps (Havok AI's character step, Link's
movement and collision) are mostly float math the lifter runs through an exactness check on every single op (about 25
instructions around each SSE op). This lifts straight runs of that math twice: a plain-SSE copy checked once, and the
exact copy kept as the fallback.
It's on by default. Reports from Intel, Linux and CPUs without AVX2 are still welcome (the SSE path has only run in
the unit test so far):
HOS_NO_FASTFPturns it off on the same exe to compare.How it works
a64_lift.py(ff_region,FASTFP_HEADER): a straight run of instructions with no label, store, call, branch orsystem-register access, holding at least 8 single-precision adds, subtracts, multiplies, fused multiply-adds, divides
or square roots, is emitted twice:
asm volatile, in Arm's operand order; multiply-adds asvfmadd231sson FMA3builds), between one MXCSR flag clear and one read;
With no invalid, denormal or underflow flag (FTZ on under FPCR.FZ, so a tiny result is flushed and flagged), IEEE single
precision round-to-nearest gives Arm's result; a multiply-add rounds once, as on Arm. A few results x86 gets right in
value but not in Arm's flags, or not in value under FZ, raise the invalid flag on purpose (
a64_ff_guard_):Divide-by-zero and overflow give the same infinities; they and inexact are or-ed into FPSR. On an invalid, denormal or
underflow flag, the registers the run names are put back and the exact copy runs. The 2-lane vector ops load their
upper lanes as zeros, so those raise no flags.
The gate is the existing
RECOMP_FPX_OPEN(FPCR at its defaults apart from FZ, FPSR.IXC set). Without FMA3 amultiply-add calls the exact helper inside the fast copy; non-x86 builds compile the exact copy only.
Switches
A64_FASTFP=0(lift time): off;hot: the hot functions only;check: both copies every time, the results compared,fastfp check:lines in the log (counts per chunk, and any mismatch with the run's guest address).HOS_NO_FASTFP(run time): the exact copies only, to compare on the same exe.Results (Ryzen 9 3900X, Windows; measured on
ebbba2ea, before the rebase and before the result checks of1e6b7a2f, which add a compare per op)Cavern, Jabu-Jabu's Belly, the Water Temple; 42–105 million region runs each): no wrong result. The Water Temple
reports 16 mismatches, all in one run (0x831e6808). Rerunning its fast copy from the same inputs gave the exact
copy's result every time; the one register that differed holds a float the run loads, which another thread writes
between the two copies. Check mode runs each region twice, so it can see that; a normal build loads it once.
collision functions 44–56% less.
Havok AI's character step drops in each pair, 9.4 → 7.9 ms of CPU a frame (−16%), and the BaseProc executors
don't rise (118 → 114 and 121 → 88 ms).
Since the draft
9f060614, @Jiro131's). They are about half the float ops in Link's movement andcollision code, so without them most of its runs stayed under 8 ops; in Town and Castle one hot file also fell back to
the exact copy in about 55% of runs, which it no longer does.
1e6b7a2f): the three cases above, found by @Jiro131'stests/test_fastfp_fma.py(a product rounded up to 2^-126, a subnormal beside a NaN) and by the same test once thedivide was added to it (a subnormal over a zero). They need inputs at the very bottom of the float range or NaNs, so
check mode never met them in game, but the fast copy has to be exact.
it, the fast copies use their own
vfmadd231ss.tests/test_fastfp_regions.pylinks libm only off Windows (the Windows runner's MSVC-target clang has nom.lib).CI: Windows part 1 fails on
tests/test_psychov35.py, from #2481 on main (scanfis deprecated under-Werrorwith the runner's MSVC-target clang), not from this PR.
Tests
Both game-free.
tests/test_fastfp_fma.py(@Jiro131's, extended): each fast op alone in the MXCSR window against the exact bodythe lifted code falls back to (
recomp_fpe_fma32,_div32,_add32,_sub32; the vector ops lane by lane, 4 lanesand 2), value and FPSR, FZ off and on: random operands weighted to the underflow and overflow edges, NaNs, subnormals
and cancellations, plus an edge list. 0 mismatches in 2–8M checks per op. It fails if any of the result checks is
removed, or the 2-lane ops load four lanes.
tests/test_fastfp_regions.py: what may join a region, and a whole region from the real header against plainIEEE results at
-march=x86-64-v3andx86-64-v2(200,000 random runs; a multiply-add rounded once; NaN results,0 * inf, a denormal operand and a tiny result under FZ take the exact copy with the inputs put back; overflow and
divide-by-zero stay fast with FPSR set; the gate closed or
HOS_NO_FASTFPtake the exact copy). It fails if the AVXoperands are swapped or the invalid flag stops sending a region to its exact copy.
How to help test
mainis worth a report.A64_FASTFP=check(slower to build and run), play anywhere, and look inhookshot_log.txtforfastfp check: MISMATCH: there should be none.HOS_THREAD_TIMES=5with and withoutHOS_NO_FASTFP(theNavMeshCAStepDepWorker1share).🤖 Generated with Claude Code
Tested on Intel as asked.
Setup:
mainatb744b1ec, against the same tree without it;hookshot_vk.dll, settings and caches in every game folder.1. This PR as it is
tests/test_fastfp_regions.pypasses atx86-64-v3andx86-64-v2.fast float regions (A64_FASTFP=1): 13114, 250040 float ops in them.Where the exact float time goes: samples with an exact helper as the leaf, from
HOS_SAMPLE_ALL(every game thread every 2 ms, about 7,000 instants per build, walking around Kokiri):a64_fpx_fma32_fma32a64_fpx_mul32_fma32a64_fpx_add32_add32a64_fpx_sub32_sub32a64_fpx_div32_div32a64_fpx_sqrt32_sqrt32The PR removes about 30% of these samples.
fma32_fma32is untouched and is now more than half of what is left.2. Fused multiply-add on top:
Jiro131/Hookshot@perf/fastfp-fmaThe branch is one commit on top of aaf991df, so you can cherry-pick it if you want it.
What the commit does:
a64_ff_fma32_fma32is a singlevfmadd231ssunder__FMA__. Without FMA3 (x86-64-v2) it stays the exact helper.fma32_fma32is added toFF_CONV.What x86 FMA gets right: it rounds once, as Arm does, and returns NaNs in Arm's order (addend, then a, then b).
Where it differs from Arm, probed on this CPU:
0x3f000001 * 0x00fffffe, whose exact product is (1 - 2^-46) · 2^-126. x86 gives0x00800000and only the inexact flag. Arm gives UFC, and0under FZ.How the commit handles those two cases:
vcomisson a NaN.How it was checked:
tests/test_fastfp_fma.py(new). It builds the realrecomp_fpe_fma32the waytest_fpx_sticky.pydoes, and runs the op alone inside thea64_ff_begin/a64_ff_endwindow.vfmadd213ss).tests/test_fastfp_regions.py.fma32_fma32is dropped fromFF_CONV.A64_FASTFP=checkbuild played in Kokiri.code_022_1,code_023_1,code_024_3,code_024_7) are only 57–69% clean.17068, 315994 float ops in them: runs with FMAs now reachA64_FASTFP_MIN.3. A correctness issue in the fast FMUL (and probably FDIV)
The same test runs
a64_ff_mul32_fma32againstrecomp_fpe_fma32with a signed-zero addend, the way FMUL reaches the exact body. It finds 15,348 kept results out of 8M that differ from Arm's. They come from two cases.A product that rounds up to 2^-126. This is the tininess difference above. FZ on gives a wrong value; FZ off gives only a missing UFC.
Under FZ, a subnormal times a quiet NaN. Arm flushes the subnormal and sets IDC. x86 raises no denormal flag when the other operand is a NaN.
These inputs are rare in game code, but the fast copy is meant to be exact.
|r| == 2^-126, sends the run to the exact copy) would close both cases formulss.mulps: it would need a per-lane check.divss: I expect the 2^-126 case there too, but haven't tested it.4. Frame times
Kokiri Forest, Link standing still where the save puts him, no input, uncapped (
HOS_FPS_MAX=1000), 30 s per run,HOS_PRESENT_TRACE.The baseline itself moved 3% between sessions, so all of these differences are noise.
Why the frame barely moves:
HOS_CHAINwould show which jobs are on it.Measured and written with Claude Code (Claude Opus 5.5).
LNK1181: cannot open m.lib).@Jiro131 thank you, both cases reproduce here (Ryzen 9 3900X):
0x3f000001 * 0x00fffffegives0x00800000with only the inexact flag, FZ or not, and a subnormal times a quiet NaN raises no denormal flag.9f060614, you as author). It replaces the one I had pushed the same day, which was the same design without your 2^-126 guard.1e6b7a2fcloses the rest:tests/test_fastfp_fma.pynow holds all eight fast ops to the exact bodies (--strict-mulis gone): 0 mismatches in 2–8M checks per op, and removing any of the checks fails it.@Joey-Magnus
m.libis fixed (0840188e), and the branch is rebased past the ASTC fix. Windows part 1 now fails only on main'stests/test_psychov35.py(#2481).test_fastfp_fma.py;A64_FASTFP.HOS_NO_FASTFPat run time,A64_FASTFP=0at lift time.Test report — fast float regions
Machine
4a53c897+ zstd/BC7 (my playable fork), with the current PR head1e6b7a2fcherry-picked onto it. The exe is 526,973,440 bytes (503 MB) against 470,541,312 for the same build without the PR; the DLL and shader caches are the 4a53 ones.A64_FASTFP=1(default), hot chunks, PGO: 17,068 regions / 315,994 float ops, matching the author's report. Split into 8 hot pieces.HOS_NO_FASTFP=1(exact copies only), headless, 240 s, static Market (!cheat 1003) after the save loads; the same fixed save is restored before each run.Result (static Market, CPU-heavy)
HOS_NO_FASTFP=1)HOS_PROFILEallThe fast path is +2.55 fps on average (~4.7%) in this scene, and the last part of the run is consistently higher (52–59 vs 50–52). This is the same direction as the author's Temple of Time result (41.5 -> 45.7 fps) and the Havok AI character step. The Kakariko route with two teleports was noisy on this machine and is not used for the A/B; the author already notes that Kokiri/Kakariko frame time is mostly job-chain waits, so no claim there.
Checks
tests/test_fastfp_regions.py: pass, including the real-header x86-64-v3 and x86-64-v2 runs.tests/test_fastfp_fma.pycould not be built locally:a64emitfailed with'algorithm' file not foundin this PC's MSVC-target clang setup, the same local toolchain issue astest_fpx_sticky.py. That is not the PR's code; the author reports the FMA test with 0 mismatches and the 2^-126/NaN/subnormal fixes in1e6b7a2f.e8cd68b8.Caveats
4a53c897+ zstd/BC7, not the PR's latest main; the PR's own base isfce53453. The fast path's effect can differ with the newer lift/main.HOS_NO_FASTFP/A64_FASTFP=0turn it off.Thanks, this is careful work, and the fix-up after @Jiro131's report is solid. I went through the fast path's semantics. One thing still needs checking before I'd approve: how the regions interact with the lifted-text edits in
nx2/oot_patches.py.What I checked and agree with
RECOMP_FPX_OPEN, and an MSR (or an MRS of FPSR) ends a run, so FPCR can't change inside a region.mulps), the subnormal over zero divide pre-check, and the 2-lane ops loading zeros into the upper lanes.comissis the right choice for the spoil, since it signals on a quiet NaN. Sums can't round to 2^-126, so add and sub without the tiny check is fine.c->x[31], soREG_REFcovers it. NZCV and FPSR are saved and restored, andA64_LOCAL_REGS/A64_LOCAL_FLAGSrename the save/restore along with the body. Labels, stores, calls,goto/returnand sync marks all end a run, so nothing can jump into the middle of one.__AVX__andvfmadd231ssunder__FMA__, and both followbuild.py'shost_march()/RX_MARCH. So a no-AVX2 PC built at x86-64-v2 or x86-64 gets the legacy encodings and the exact FMA helper. I found no AVX2-only path.A64_FASTFP=0the emitter's output matches main (no joins, no header). With it on, a run underA64_FASTFP_MINis joined back exactly as before, so functions without a region are byte-identical.m.libpoint is fixed: both new tests pass on the Linux and Windows runners. The remaining red checks come from main, not this PR:test_env_on.pyflagsHOS_FPS_LOGinhost/oot_high_fps.c, plustest_psychov35.pyandtest_pcache_header_read.py.Needs a check: regions vs.
nx2/oot_patches.pyThe patches edit the finished C by matching the lift's exact text, and a region changes that text in two ways. It duplicates the text (fast copy first, exact copy second), and it wraps the run in the region block, so text that spans a run's edge no longer matches. Most patches then miss and only warn, but
patch_frame_secondswould succeed silently on the wrong copy. It requires exactly onea64_fpx_div32_div32in the function. If the step divide sat inside a region, the fast copy would holda64_ff_div32_div32, the count would still be 1, and the hook would go into the exact copy only. The fast copy (97–99% of runs) would skip it, and above a 60 fps cap the game would run fast again. Every timing in the description is below 60 fps, where the hook does nothing, so none of them would show this.I can't tell from here whether a region forms at those sites. Could you check your lift: no new
oot_patcheswarnings, and none of the patched places inside anL_ff_block? A cheap guard would also help: end a run at those functions, or have the frame-seconds patch refuse a body that containsa64_ff_div32_div32. That keeps a future lift change from breaking it quietly.Default on: given the check-mode coverage across eight saves, the per-op bit-exact tests and
HOS_NO_FASTFPto turn it off at run time, I'm fine with on by default once the patch-site check is answered.One nit inline.
@ -685,0 +773,4 @@#if RECOMP_FPX_HOST == 2 && defined(__x86_64__) && !defined(__EMSCRIPTEN__)#include <stdlib.h>#include <stdio.h>static int a64_ff_enabled_(void) { static int v = -1; if (v < 0) v = !getenv("HOS_NO_FASTFP"); return v; }Nit: this reads the switch the old way, so
HOS_NO_FASTFP=0turns the fast path off. #2042 moved the project toenv_onsemantics (unset, empty, 0, false, off and no all mean off).test_env_on.pyonly scanshost/andgpu/vk, so it won't catch this one. A small inline equivalent here would keep it consistent.A64_FASTFPat lift time likewise only treats the literal0as off.A correction to the frame-time table in my comment above (section 4, "Frame times").
In session 2, the "this PR + FMA" game folder had lost its
nvngx_dlss.dllwhen I installed that build. Its two runs went without DLSS: the log saysNVIDIA DLSS: not available: no nvngx_dlss.dll. The baseline in the same session started NGX. Session 1 is not affected: both sides started NGX.Both sides of session 2 show a GPU-bound wait of 0.0 ms at about 2,500 draws, so the game was CPU-bound either way, and the missing DLSS probably changed little. Still, those two pairs are not like for like. Please read only session 1 as an A/B. The conclusion does not change: no measurable fps gain while standing still in Kokiri Forest, which matches what you see there.
Sections 1 to 3 (the profile, the multiply-add commit and its tests, the FMUL cases) are not affected.
Written with Claude Code (Claude Opus 5.5).
My earlier points are settled: libm links only off Windows, the ASTC failure was main's, and the check-mode runs plus
tests/test_fastfp_fma.pypersuade me on default-on. I merged it into current main (including #2748's parallel emission) and rantest_fastfp_fma.py,test_fastfp_regions.py,test_fpx_sticky.py,test_mark_groups.py,test_spawn_emit.pyandtest_stale_hot_pieces.py: all pass.Still open is hckrman101's question on
nx2/oot_patches.py.patch_frame_seconds(:352) only checks thata64_fpx_div32_div32appears once in f_80706be0 or f_83b6ea90. If that divide is inside a region, the fast copy callsa64_ff_div32_div32, the count is still 1, and the hook goes into the exact copy only. Above a 60 fps cap the game would run fast again in the 97-99% of runs that keep the fast copy, and none of your timings are above 60. Please check those two functions in your lift and add a guard: end a run there, or have the patch refuse a body witha64_ff_div32_div32.The nit stands too:
HOS_NO_FASTFPis read with!getenv(a64_lift.py:776), soHOS_NO_FASTFP=0turns the fast path off.