Skip to content

Composite AnyMask: test the OR of both halves - #15

Merged
Auburn merged 1 commit into
masterfrom
perf/composite-anymask
Oct 17, 2026
Merged

Auburn merged 1 commit into
masterfrom
perf/composite-anymask

Conversation

@Auburn

@Auburn Auburn commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Change

AnyMask for composite registers (Generic/Functions.h, anything wider than the native register) was AnyMask( a.v0 ) || AnyMask( a.v1 ). It is now AnyMask( a.v0 | a.v1 ): one OR of the halves, then one native test. Wider composites recurse through the composite |, so a 1024 bit mask on SSE is three ORs and a single movmskps.

The result is the same for any input, not only well-formed masks. Every native AnyMask either looks for any set bit (AVX512, NEON, AARCH64, WASM, scalar) or for any set sign bit (movmskps, pmovmskb), and OR keeps both.

Why

|| short-circuits, so compilers test and branch on each half: two movmskps + test + jne on SSE and AVX2, two kmov + test on AVX512, and a horizontal reduction and branch per half on the NEON level. Both halves are already in registers, so one OR (orps, korw, vorr) and one test is cheaper and leaves one branch instead of two.

Instruction counts in FastNoise2's per feature set objects, before → after, clang-cl 23:

SSE2 SSE4.1 AVX2 AVX512
FastNoise2 master: movmskps / kmovd 22 → 6 21 → 6 18 → 3 33 → 12
FastNoise2 master: jne 209 → 199 208 → 198 248 → 238 253 → 232
FastNoise2 perf branch: movmskps / kmovd 158 → 125 92 → 57 34 → 6 81 → 39
FastNoise2 perf branch: jne 875 → 855 869 → 844 874 → 850 872 → 816

MSVC 19.51 (master) goes from 51 to 26 (v)movmskps on each of SSE2, SSE4.1 and AVX2. For ARM (clang 23, compile only) the NEON level object shrinks from 53,491 to 53,418 instructions, and the AARCH64 level object doesn't change.

Speed

FastNoise2 master only calls AnyMask in two nodes: GeneratorCache, and Terrace with a smoothness generator. So on master this is mostly an A/A check. I built FastNoise2 master (8c2d080) against this branch and against FastSIMD master and timed them with its benchmark harness on a Ryzen 9 7950X (one pinned core), and on an Apple M1. Each figure is the median of 3 processes with alternating order, over the default 63 node/dimension cases per feature set. Numbers are new/old time:

FastNoise2 master SSE2 SSE4.1 AVX2 AVX512 NEON AARCH64
clang-cl 23 0.999 1.000 0.996 0.999
MSVC 19.51 0.999 0.997 0.998 1.000
GCC 16 (Linux) 1.000 0.998 1.000 1.002
Homebrew clang 23 (M1) 1.000 0.999

Only the GeneratorCache and Terrace functions change, and GeneratorCache on its own is 1.01 (clang-cl) and 1.00 (MSVC) over 9 processes. A few rows move by more than 3% in both directions, but their code is identical, so that is code placement.

FastNoise2's performance branch uses AnyMask in hot loops: the cellular cell checks, the Simplex/SuperSimplex vertex skips, the domain warp checks and the grid loop. Same method, built against FastSIMD master and this branch:

FastNoise2 perf branch SSE2 SSE4.1 AVX2 AVX512 NEON AARCH64
clang-cl 23 1.004 1.000 1.000 1.002
GCC 16 (Linux) 1.000 1.000 1.000 1.001
MSVC 19.51 0.99-1.01 0.99-1.01 0.997-0.998 see below
Homebrew clang 23 (M1) 0.995 0.999
Apple clang 21 (M1) 0.995 0.995
  • ARM: Demo_MountainTerrain 4D 0.90 (NEON, both compilers) and 0.91 (AARCH64, Apple clang), SuperSimplex 4D 0.96 (NEON), domain-warped SuperSimplex 3D 0.95-0.96. No row is slower than 1.02. NEON's SuperSimplex 4D GenFn drops from 2429 to 2367 instructions.
  • x86: about neutral, with rows moving both ways. MSVC 19.51 CellularLookup AVX512 2D is 0.88. GCC 16 is slower on 2D cellular (1.03-1.075 on SSE2/SSE4.1/AVX2, where it spills a little more) and faster on SuperSimplex SSE2 4D (0.87). The MSVC SSE numbers come from three runs on a machine that was busy at the time.
  • Earlier measurements on the performance branch, made on a heavily loaded machine, gave clang-cl 23 cellular rows 0.992 on AVX2 and AVX512 (CellularDistance AVX2 2D 0.94) and MSVC 19.51 CellularLookup AVX512 2D 0.90.

MSVC AVX512: merge after the native copies PR

Without #13, this change makes MSVC AVX512 slower wherever a 32 lane AnyMask sits on a hot, hard to predict branch. On the performance branch, SuperSimplex 4D is 1.08 (19.51) and 1.17 (19.44), with all 5 processes agreeing, and FBm/Mountain 4D are 1.02-1.15. The function itself has fewer instructions (2670 → 2565) and fewer stack accesses.

The cause is the MSVC split copy that the native copies PR fixes. MSVC stores a 32 lane mask (two __mmask16) with two 16 bit stores, then reads it back with one 32 bit load, which can't be store forwarded. The || form tested the low half straight from its register and usually skipped the reload. The OR always waits for it, and the branch resolves later.

#13 keeps masks in registers and adds a 32 lane kortestw overload. That overload is more specialised than the generic composite one, so it also ORs both halves before the test. With that PR applied, the MSVC AVX512 object is instruction-identical with or without this one, for 19.51 and 19.44 (0 of about 2470 functions change), and SSE/AVX2 change as above. FastNoise2 master isn't affected either way (MSVC 19.51 AVX512 1.000 above).

Correctness

FastNoise2 output, comparing the build against this branch with the build against FastSIMD master on the same compiler:

  • Master: identical for all 1212 harness cases with clang-cl 23, MSVC 19.51, MSVC 19.44 and GCC 16, and all 606 cases on the M1 (Homebrew clang 23, Apple clang 21).
  • Performance branch: identical with clang-cl 23, MSVC 19.51 and 19.44. GCC 16 differs in 1 case (domain-warped SuperSimplex AVX512 3D, up to 3.6e-6 relative). On the M1 it differs in 6 cases (clang 23) and 12 cases (Apple clang 21), all domain-warped SuperSimplex 2D/4D, up to 3.1e-6. The compilers arrange the surrounding fast-math float code differently once the branch changes. The masks and the branch outcome are the same.

Testing

  • FastSIMD tests, Release, full count (4,194,304 inputs), seeds 24301, 1 and 2. All exit 0 with the same pass/known lists as master:
    • clang-cl 23, MSVC 19.51 and GCC 16: 628 passed, 52 known failures.
    • Emscripten 6.0.12 (node): 628 passed, 12 known failures.
    • Homebrew clang 23 and Apple clang 21 on an M1: 628 passed, 88 known failures.
  • m32 any mask and m32i any mask cover the change on every level wider than its native register, and on SCALAR (the reference) at every width. As a check, I changed it to AnyMask( a.v0 ). Both tests then fail at 128, 256, 512 and 1024 bit on every x86 level (clang-cl 23).
  • Builds: FastNoise2 for WASM (Emscripten 6.0.12, 0 warnings), and NEON/AARCH64 with clang 23 (compile only).

Other PRs

AnyMask( v0 ) || AnyMask( v1 ) compiles to two tests and two branches on
SSE/AVX2 (and kunpck + kmovd + test on some AVX512 sites). Both halves are
already computed, so OR them and test once.
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