Repository navigation
perf(lift): fast float regions, plain SSE checked once by MXCSR with the exact copy as fallback - #2433
perf(lift): fast float regions, plain SSE checked once by MXCSR with the exact copy as fallback#2433jordanella wants to merge 4 commits into
Conversation
|
Tested on Intel as asked.
Setup:
1. This PR as it is
Where the exact float time goes: samples with an exact helper as the leaf, from
The PR removes about 30% of these samples. 2. Fused multiply-add on top:
|
| baseline | this PR | this PR + FMA | |
|---|---|---|---|
| session 1, fps | 58.94 | 58.75 | – |
| session 2, pair 1, fps | 60.77 | – | 61.46 |
| session 2, pair 2, fps | 60.44 | – | 60.26 |
The baseline itself moved 3% between sessions, so all of these differences are noise.
Why the frame barely moves:
- The exact helpers were about 15% of the game threads' running samples.
- The main thread is blocked about 73% of the time, mostly waiting on the job system, while only 0–2 other game threads run.
- So faster float math pays off only where it shortens that serial chain. perf: a frame critical-path recorder (HOS_CHAIN), and an opt-in presenter thread that takes NVIDIA's present wait off the game's thread #2325's
HOS_CHAINwould show which jobs are on it.
Measured and written with Claude Code (Claude Opus 5.5).
Joey-Magnus
left a comment
There was a problem hiding this comment.
- Its own new test fails to link on Windows (
LNK1181: cannot open m.lib). - The other Windows failure is main's old ASTC test (#2397).
- It's on by default "so a build tests it", which changes generated code for every player and grows the exe from 470 to 507 MB. That should be opt-in before it's ready.
aaf991d to
0840188
Compare
…the exact copy as fallback A straight run of single-precision float ops (no label, store, call, branch or system register access; at least 8 adds, subtracts, multiplies, divides or square roots) is lifted twice: a plain SSE copy between one MXCSR flag clear and one read, and the exact copy. With no invalid, denormal or underflow flag the fast result is Arm's (FTZ on under FPCR.FZ, ops in Arm's operand order); divide-by-zero, overflow and inexact are or-ed into FPSR; otherwise the registers the run names are put back and the exact copy runs. On by default; A64_FASTFP=0 off, =hot the hot functions only, =check both copies every time with mismatches logged; HOS_NO_FASTFP at run time takes the exact copies only. OoT, Temple of Time (Ryzen 9 3900X): 13,114 regions, 250,040 ops; check mode 0 mismatches in tens of millions of runs; Havok AI's character step 5.5 -> 4.1 ms of CPU a frame. tests/test_fastfp_regions.py checks the regions game-free. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Windows runner's clang targets MSVC, which has no m.lib; Windows keeps the math functions in the C runtime. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
e8cd68b to
1e6b7a2
Compare
|
@Jiro131 thank you, both cases reproduce here (Ryzen 9 3900X):
|
The fast float regions left a64_fpx_fma32_fma32 (FMADD/FMSUB/FNMADD/FNMSUB, FMLA/FMLS S lanes) as an exact helper call; it is the hottest exact float helper left in a Kokiri Forest profile. On x86-64-v3, vfmadd231ss rounds once like Arm and takes its NaN in Arm's order. Two cases differ and now send the run to its exact copy (an invalid flag raised in the window): any NaN result (x86 returns 0 * inf + qNaN's NaN with no flag, Arm the default NaN with IOC), and a result of magnitude 2^-126 (x86 detects tininess after rounding, Arm before: UFC, and a zero under FZ). Without FMA3 the op stays the exact helper. tests/test_fastfp_fma.py checks the op in the MXCSR window against recomp_fpe_fma32 (8M checks, 0 mismatches); it also reports the fast FMUL's 15348 mismatches (the same 2^-126 case, and subnormal x qNaN under FZ). Assisted-by: Claude Code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three cases kept a fast result that differs from Arm's, found by tests/test_fastfp_fma.py (Jiro131's): - a product, quotient or multiply-add rounded up to exactly 2^-126: x86 detects tininess after rounding, Arm before, so Arm sets UFC and, under FZ, gives a zero; - a NaN beside a subnormal operand: x86 raises no denormal flag, Arm under FZ flushes the subnormal and sets IDC; - a subnormal over a zero: x86 gives an infinity with divide-by-zero only, Arm under FZ divides 0 by 0 (the default NaN, IOC). Each op now checks its result (any NaN; exactly 2^-126 for the multiply, divide and multiply-add; the divide's operands for the third case) and raises the invalid flag, so the run takes its exact copy. The 2-lane vector ops load their upper lanes as zeros, which no longer raise flags of their own (an overflow there set OFC). tests/test_fastfp_fma.py now holds every fast op, vector ones lane by lane, to the exact bodies: 0 mismatches in 2-8M checks each; removing any of the checks fails it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Test report — fast float regionsMachine
Result (static Market, CPU-heavy)
The 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
Caveats
|
hckrman101
left a comment
There was a problem hiding this comment.
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
- The MXCSR window looks right: exceptions masked, round to nearest, DAZ off, FTZ only under FPCR.FZ. The gate is the existing
RECOMP_FPX_OPEN, and an MSR (or an MRS of FPSR) ends a run, so FPCR can't change inside a region. - The exact helpers that stay inside a fast copy (fp64 ops, compares, conversions) never read or write MXCSR. The window's only difference from the host contract is FTZ, and FTZ only changes a result when it raises UE. So anything the window changes sends the run back to its exact copy. At worst that costs a fallback; it never gives a wrong result.
- Jiro's cases are closed in the code: the NaN and 2^-126 guards on mul, div and fma (per lane for
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. - The restore is complete. SP is
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. - CPU coverage: the header uses only SSE2 intrinsics. The VEX forms sit under
__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. - Generated code: with
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. - @Joey-Magnus's
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.py
The 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_seconds would succeed silently on the wrong copy. It requires exactly one a64_fpx_div32_div32 in the function. If the step divide sat inside a region, the fast copy would hold a64_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_patches warnings, and none of the patched places inside an L_ff_ block? A cheap guard would also help: end a run at those functions, or have the frame-seconds patch refuse a body that contains a64_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_FASTFP to turn it off at run time, I'm fine with on by default once the patch-site check is answered.
One nit inline.
| #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; } |
There was a problem hiding this comment.
Nit: this reads the switch the old way, so HOS_NO_FASTFP=0 turns the fast path off. #2042 moved the project to env_on semantics (unset, empty, 0, false, off and no all mean off). test_env_on.py only scans host/ and gpu/vk, so it won't catch this one. A small inline equivalent here would keep it consistent. A64_FASTFP at lift time likewise only treats the literal 0 as off.
…the state files The ledger records the sync at upstream's 3e32949 (pull request 3), upstream's own CPU profile (NotRexed#2424) and the open pull requests found since: NotRexed#2470 (sound and the physics step above 60), NotRexed#2325 (the frame's chain of waits), NotRexed#2433 (fast float regions), NotRexed#2487 (wake-ups on Linux). correctness.json takes the water ripples as fixed upstream (NotRexed#2409), the Deku Tree platform's cause as reported in NotRexed#2470, and a new entry for the sound loss. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…the state files The ledger records the sync at upstream's 3e32949 (pull request 3), upstream's own CPU profile (NotRexed#2424) and the open pull requests found since: NotRexed#2470 (sound and the physics step above 60), NotRexed#2325 (the frame's chain of waits), NotRexed#2433 (fast float regions), NotRexed#2487 (wake-ups on Linux). correctness.json takes the water ripples as fixed upstream (NotRexed#2409), the Deku Tree platform's cause as reported in NotRexed#2470, and a new entry for the sound loss. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
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 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). |
Joey-Magnus
left a comment
There was a problem hiding this comment.
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.py persuade me on default-on. I merged it into current main (including #2748's parallel emission) and ran test_fastfp_fma.py, test_fastfp_regions.py, test_fpx_sticky.py, test_mark_groups.py, test_spawn_emit.py and test_stale_hot_pieces.py: all pass.
Still open is hckrman101's question on nx2/oot_patches.py. patch_frame_seconds (:352) only checks that a64_fpx_div32_div32 appears once in f_80706be0 or f_83b6ea90. If that divide is inside a region, the fast copy calls a64_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 with a64_ff_div32_div32.
The nit stands too: HOS_NO_FASTFP is read with !getenv (a64_lift.py:776), so HOS_NO_FASTFP=0 turns the fast path off.
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 of 1e6b7a2, 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
collision 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.
tests/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