Skip to content

MSVC AVX512: copy 512 bit registers and masks through their native type - #13

Merged
Auburn merged 2 commits into
masterfrom
perf/msvc-avx512-native-copies
Oct 17, 2026
Merged

Auburn merged 2 commits into
masterfrom
perf/msvc-avx512-native-copies

Conversation

@Auburn

@Auburn Auburn commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Problem

MSVC copies a trivially copyable 64 byte struct with two 32 byte stores. When that copy is next read as one 512 bit register, the load can't be store forwarded and stalls. On AVX512 Register<float, 16> and Register<int32_t, 16> are such structs, and FastSIMD copies them all the time: every composite op (Register{ f( a.v0 ), f( a.v1 ) }), operators that copy and then compound assign, and arguments passed by value. 32 lane composite masks have the same problem at a smaller size: two 16 bit stores, then one 32 bit load.

As a result MSVC's AVX512 build of FastNoise2 is slower than its AVX2 build. With 19.51 on a grid it takes 1.15x the AVX2 time on average, and 34 of 63 benchmark rows are slower. clang and GCC don't split these copies.

Change

MSVC only (_MSC_VER && !__clang__). clang-cl, clang and GCC compile the same code as before.

  • f32x16.h, i32x16.h: a user-provided copy constructor and copy assignment that copy .native.
  • mNx16.h: the same for the AVX512 mask, plus a 32 lane AnyMask overload that tests both halves with one kortestw instead of moving each half to a general purpose register.

Cost

The 512 bit registers are no longer trivially copyable on MSVC, so they stop being HVAs. MSVC now passes them to non-inlined functions by reference to a caller copy, not in zmm registers. In FastNoise2 that is the per-call Gen boundary, so a few very cheap nodes get slower (numbers below). FastNoise2 can avoid this on its side by passing generator arguments as a plain aggregate of the native halves. FastNoise2's performance branch does that, and there no row is slower.

Measured

FastNoise2 master (8c2d080) built against this branch, compared with the same source built against FastSIMD master, on a Ryzen 9 7950X. Both builds were loaded in one benchmark process and timed interleaved on one pinned core, in 3 processes with the A/B order alternating, and took the median of the per-process ratios. The default case set has 63 rows per feature set (21 generators/trees x 2D/3D/4D), run on a uniform grid and on random positions. Time ratio, lower is faster:

MSVC 19.51 (VS 2026), grid random MSVC 19.44 (VS 2022), grid random
AVX512 0.671 0.680 0.683 0.705
AVX2 (code unchanged) 0.997 1.011 1.002 1.003
  • AVX512 against AVX2 in the same MSVC build: AVX512 took 1.15x the AVX2 time before and takes 0.77x after, with no row slower than AVX2 (19.44: 1.14x before, 0.78x after).
  • Biggest gains: White 0.19-0.34, cellular 0.42-0.71, Demo_CellularCaves 0.62-0.66, Simplex/Perlin 3D/4D 0.53-0.84.
  • Slower rows: Value 3D on random positions is 1.09 (19.51) and 1.07 (19.44). On 19.51 grids, 2D Perlin is 1.06, DWSimplex 1.05 and Simplex 1.04. Across all 304 AVX512 cases (19.51, grid, --all) the geomean is 0.670. 23 rows are above 1.02, all of them 2D and mostly cheap combinator nodes such as Op_PowInt 1.18, Op_RemoveDimension 1.15 and Op_SeedOffset 1.15. In those, passing arguments by reference costs more than the saved copies.
  • SSE2, SSE4.1 and AVX2: the MSVC object code is instruction identical to master.
  • Code: in the MSVC AVX512 object, 32 byte store / 512 bit reload pairs drop from 2017 to 566 (19.51) and from 1646 to 566 (19.44). vextract*64x4 drops from 3102 to 659.

Earlier numbers come from FastNoise2's performance branch, which uses composites more heavily. They were measured on a busy machine. The first commit alone gave 0.66 (19.51) and 0.71 (19.44) for the AVX512 geomean. The mask commit gave another 0.96 on 19.51 (0.82-0.89 on cellular 3D/4D). Together with the FastNoise2-side argument change, 19.51 AVX512 came to 0.61 of that branch's time, with no row slower.

Correctness

This change also happens to avoid two MSVC SSA optimiser bugs on structs of two __m512i, which is what a 32 lane int32v is. I checked both with short repros on FastSIMD master's FS::Register<int32_t, 32> / FS::Register<float, 32> (/O2 /arch:AVX512). Both build correctly with /d2SSAOptimizer-:

  • seed ^ ( x ^ y ^ z ) next to seed ^ x ^ y ^ z: 19.51 and 19.44 get the upper 16 lanes wrong. With this change, no lanes are wrong.
  • ( Convert( x ) ^ Convert( y ) ) & 1 (FastNoise2's Checkerboard): 19.44 folds it into a wrong vpternlogd and gets 8 of 32 lanes wrong. With this change, none are wrong.

These are compiler bugs and this only sidesteps them, but the effect is real. Under VS 2022, FastNoise2's Checkerboard AVX512 output is wrong on master and now matches clang-cl. In FastNoise2's performance branch, 19.51 produced garbage in about 140 AVX512 rows, and this change fixes them.

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

  • clang-cl 23, GCC 16 and clang 23: the DLL/.so is byte identical, apart from the PE timestamp.
  • MSVC 19.44: identical, except the 3 Checkerboard AVX512 rows, which now match clang-cl exactly.
  • MSVC 19.51: SSE2, SSE4.1 and AVX2 are identical. AVX512 changes at the rounding level: 199 of 303 rows differ by more than 1e-7 of the output range. At 1e-3, the only differences are far-from-origin and jittered cellular samples that already differ between MSVC and clang-cl. With the copies kept in registers, /fp:fast contracts more multiply-adds into FMAs (7846 -> 8008 in the AVX512 object). The output is not further from clang-cl's: 29 rows differ from it by more than 1e-3, against 32 before.

Testing

FastSIMD_tests, Release, 4,194,304 random inputs per test, seeds 24301, 1 and 2:

  • MSVC 19.51, MSVC 19.44, clang-cl 23 and GCC 16: 628 passed, 52 known failures, 0 failed, the same list as master.
  • Emscripten 6.0.12 (node): 628 passed, 12 known failures, 0 failed, the same as master.
  • The changed code runs in the 512 bit tests (native AVX512 registers and masks) and the 1024 bit tests (composites of them). m32 any mask and m32i any mask at 1024 bit cover the new AnyMask overload: changing it to ignore the upper half makes both fail.
  • Builds: FastNoise2 for WASM (Emscripten 6.0.12), and NEON/AARCH64 with clang 23 (compile only).

Related PRs

  • Route MaskedIncrement/MaskedDecrement through MaskedSub/MaskedAdd #14 also edits f32x16.h, i32x16.h and mNx16.h. It merges cleanly with this one, but whichever goes in later may need a rebase.
  • Composite AnyMask: test the OR of both halves #15 makes the generic composite AnyMask test v0 | v1. The MSVC overload here is more specialised, so MSVC AVX512 keeps kortestw, which also ORs both halves.
  • Not included: forcing MSVC to inline the composite Register copies (a defaulted FS_FORCEINLINE copy constructor and assignment). That guards against MSVC calling those copies out of line in very large functions. FastNoise2 master doesn't hit that case, and its AVX512 code is identical with or without the guard. The guard also changes MSVC's SSE2 register allocation, which makes SSE2 Perlin 3D 1.3-1.4x slower on 19.51.

MSVC lowers copies of trivially copyable 64 byte structs as two 32 byte stores,
which then get reloaded as one 512 bit register and stall store forwarding. Every
composite op (Register{ f( a.v0 ), f( a.v1 ) }), operator copy and assignment hit
it. A user-provided copy constructor and assignment that copy .native keep these
copies in zmm registers. MSVC only; clang and GCC keep the trivial copies.
MSVC writes copies of a 32 lane composite mask with two 16 bit stores and then
reads both halves with one 32 bit load, which can't be store forwarded (FastNoise's
cellular lane checks hit this twice per call). A user-provided copy constructor and
assignment on the AVX512 mask keep the halves in registers. 32 lane AnyMask uses
kortestw instead of moving both halves to general purpose registers. MSVC only.
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