Skip to content

Use compiler-inserted vzeroupper instead of manual exit clears - #196

Open
Auburn wants to merge 1 commit into
masterfrom
claude/vzeroupper-optimizations-perf-47f1af
Open

Auburn wants to merge 1 commit into
masterfrom
claude/vzeroupper-optimizations-perf-47f1af

Conversation

@Auburn

@Auburn Auburn commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Problem

FastSIMD_FastNoise was built with compiler-inserted vzeroupper turned off (/d2vzeroupper-, -mno-vzeroupper, -mllvm -x86-use-vzeroupper=0). Instead, ScopeExitx86ZeroUpper cleared the upper state by hand at the 10 public Gen* exits. The aim was to skip clears inside FastNoise2 while still returning clean to application code that isn't compiled with AVX.

Measured on a Ryzen 9 7950X (Zen 4), skipping the internal clears saves nothing, and the manual clear has costs of its own:

  • The internal clears are free. With the exit clear kept the same, turning compiler-inserted vzeroupper back on is perf-neutral: geomean 0.999 (clang-cl) and 1.000 (MSVC) on the default bench set. That holds even though the compiler then adds about 210 (clang-cl) or 500 (MSVC) clears per AVX feature set.
  • clang treats _mm256_zeroupper as clobbering all of ymm0–15. So with the RAII clear, every public function saves and restores xmm6–15, spills its result, and gets an exception-unwind funclet. GenSingle2D is 52 instructions instead of 28.
  • Not every path was covered. The New<T> node factories use ymm stores, and the partial-vector memcpy calls run with the upper state dirty. Neither had a measurable effect, but the compiler handles both automatically. Any new entry point would also have needed the RAII object added by hand.

Fix

Remove the flags and ScopeExitx86ZeroUpper. The compiler then inserts vzeroupper wherever AVX code returns to, or calls, non-AVX code.

Clearing on exit still matters. On Zen 4, legacy-SSE code runs about 3.5x slower while any ymm upper half is dirty. This PR keeps that clear and lets the compiler place it.

Output change

None. compare against master is bit-exact on all 1212 rows (SSE2, SSE41, AVX2 and AVX512; 2D, 3D and 4D), for both clang-cl and MSVC.

Testing

  • Clean exits: an SSE-only caller read the AVX in-use state (xgetbv(1)) after each of 15 public C API calls, on SSE41, AVX2 and AVX512. The ymm/zmm upper state is clean after every call, with both clang-cl and MSVC. The opmask and zmm16–31 state stays in use after any AVX-512 code. vzeroupper can't clear those, and they don't slow down legacy SSE code.

  • Builds: clang-cl, MSVC, gcc 13 and clang 18 (Linux) all build without warnings.

  • Grid benchmark: 16384-point grids, 63 cases per feature set, medians across processes, PR time / master time:

    SSE41 AVX2 AVX512
    clang-cl 0.997 0.995 1.004
    MSVC 0.999 1.000 1.007

    A few AVX512 rows are slower: 2D White, Value and SuperSimplex on clang-cl (+4–8%), and 3D/4D Simplex, Value and White on MSVC (+5–10%). Removing only the RAII object, with the flag kept, slows the same rows by the same amount. So this comes from register allocation in the spill-heavy GenUniformGrid loop, not from the clears. On clang-cl AVX512 2D, the master build happens to avoid a 512-bit store-to-load forwarding stall.

  • Calls from SSE-only code: I timed GenSingle, GenSingle plus scalar SSE math, a 16x16 grid plus an SSE pass, and a 32³ chunk plus an SSE pass. All are within 1% of master, except AVX2 GenSingle3D plus SSE (0.97) and the 16x16 grid (0.87) on clang-cl, and AVX2 GenSingle2D (0.94) on MSVC.

- Drop /d2vzeroupper-, -mno-vzeroupper and -mllvm -x86-use-vzeroupper=0 from FastSIMD_FastNoise
- Remove ScopeExitx86ZeroUpper from the public Gen* functions; the compiler now clears the upper state on every exit back to non-AVX code

This branch has not been deployed

No deployments
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.

1 participant