Skip to content

SSE2: keep i32 multiply products in place - #16

Merged
Auburn merged 1 commit into
masterfrom
perf/sse2-int-multiply
Oct 17, 2026
Merged

Auburn merged 1 commit into
masterfrom
perf/sse2-int-multiply

Conversation

@Auburn

@Auburn Auburn commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Problem

SSE2 has no 32-bit mullo, so i32x4::operator*= (ToolSet/x86/128/i32x4.h) emulates it with two pmuludq. One handles lanes 0 and 2. The other handles lanes 1 and 3 after a psrldq. Two pshufd and a punpckldq then pack the products back together.

Clang 19 sees through this sequence. When multiplies are chained, it skips the repack between them. Clang 23 doesn't, so a chain like FastNoise2's value hash, hash *= hash * A, now packs and unpacks the lanes after every multiply. On FastNoise2 master, SSE2 Value noise is 14-22% slower with clang-cl 23 than with clang-cl 19.

Change

  • Lanes 1 and 3 come from pshufd( 3, 3, 1, 1 ) instead of psrldq.
  • The two products are combined in place: or( and( mul20, low 32 bits of each 64-bit lane ), slli_epi64( mul31, 32 ) ).

pmuludq only reads the low 32 bits of each 64-bit lane, and the and/shift/or leave each product there. So a following multiply can use them without repacking. A multiply by a constant is still 6 instructions. The result is the same in every lane.

I first did this for clang only. It's as fast or faster with MSVC and GCC too, so every compiler now uses the same code. SSE4.1 and up use _mm_mullo_epi32 and don't change.

Effect

Instruction counts for a single a * b and for the chained h * ( h * A ). These are whole test functions that load their inputs and store the result, so they include those loads, the store and the constant:

Compiler Single Chained
clang 19.1 10 → 10 11 → 12
clang 23.1 11 → 10 17 → 13
GCC 13.3 12 → 12 19 → 18
GCC 16 (snapshot) 12 → 15 21 → 22
MSVC 19.51 (VS 2026) 11 → 10 18 → 18
MSVC 19.44 (VS 2022) 11 → 9 19 → 16

GCC 16 builds the and mask in general purpose registers instead of loading it. In real code that happens once, outside the loop. With clang-cl 23, FastNoise2's SSE2 Value Gen gets about 14% shorter: 341 → 291, 593 → 505 and 1038 → 895 instructions for 2D, 3D and 4D.

I built FastNoise2 master (8c2d080) against this branch and against FastSIMD master, then timed both on a Ryzen 9 7950X. Both builds are loaded in one process and timed interleaved on one pinned core. Each figure is the median of 3 processes with alternating order, over the default 63 node/dimension cases. The SSE4.1 code is instruction-identical in both builds, so it serves as a control. The numbers are new/old time:

Compiler SSE2 SSE4.1 (control)
clang-cl 23 0.982 0.998
clang 23 (Linux) 0.981 1.002
clang-cl 19 0.997 1.004
MSVC 19.51 0.993 1.003
MSVC 19.44 0.992 0.995
GCC 16 (Linux) 0.987 0.998
GCC 13 (Linux) 0.992 1.001
  • Clang 23: Value 0.84-0.89 and White 0.91-0.95. SSE2 Value with clang-cl 23 now takes 0.99-1.04 of clang-cl 19's time, down from 1.14-1.22.
  • MSVC and GCC: about 1% faster overall. Earlier runs on a FastNoise2 development branch gave the same picture: MSVC 19.51 0.985, MSVC 19.44 0.988, GCC 16 0.993 and GCC 13 0.989. On that branch clang-cl 23 measured 0.977, on a busy machine.
  • Clang 19: neutral. It already handled the old sequence well, and its chained multiply is one instruction longer now.

Single cases above 1.03 come from code placement, and they differ from compiler to compiler. Single SSE4.1 cases, whose code doesn't change, measured anywhere from 0.75 to 1.55. As a check, I rebuilt both sides with -falign-functions=64 and aligned loops, which moves the code around. A DomainWarpSimplex 4D case went from 1.22 to 0.98 (Linux clang 23) and from 1.12 to 0.98 (GCC 16), while the Value and White gains stayed the same.

Testing

  • FastSIMD tests at full count (4,194,304 inputs), seeds 24301, 1 and 2:
    • clang-cl 23, MSVC 19.51 and GCC 16: 628 passed, 52 known failures, 0 failed. Same list as master.
    • The changed code is covered by i32 multiply operator, i32 masked mul and i32 inv masked mul on SSE2 and SSE2 RELAXED. Each runs at 128 bit and at 256, 512 and 1024 bit, which are built from the 128-bit register.
    • As a check, I dropped lane 2 from the and mask. All three tests then failed at every width on SSE2 and SSE2 RELAXED.
  • FastNoise2 output:
    • With GCC 16, GCC 13 and MSVC 19.44 it is bit-identical to FastSIMD master in all 1212 node, feature set and dimension cases.
    • With clang (19 and 23), 114 of the 303 SSE2 cases change. All stay within 1e-3 of the output range. The largest change is 4.6e-5 (CellularDistance with a jitter node, 2D), and most are below 5e-7. With MSVC 19.51, 14 SSE2 cases change, by at most 2.4e-6.
    • FastNoise2 builds with /fp:fast and -ffast-math, and the compilers reorder the float maths around the changed integer code. With FASTNOISE2_STRICT_FP=ON, clang-cl 23 and MSVC 19.51 are bit-identical in all 1212 cases. The integer results don't change.
  • ARM and WASM have their own i32x4.h and aren't affected.

Other PRs

This doesn't touch the files changed by the other open performance PRs (the AVX-512 headers, Generic/Functions.h, ARM and WASM), and it merges cleanly with each of them.

clang 23 no longer recognises the psrldq/pmuludq/pshufd/punpckldq sequence as a
32 bit multiply, so chained multiplies (e.g. hash * hash * constant) repack the
lanes in between. Combining the even/odd pmuludq results in place with
and/shift/or keeps the low 32 bits of each 64 bit lane where pmuludq reads them:
a single multiply is still 6 instructions, a chained one needs no repacking.

The in-place and/shift/or form is as fast or faster than the shuffle/unpack
repack on MSVC 14.51/14.44 and GCC 16/13 too (FastNoise2 SSE2 benchmarks
0.985-0.993 of the previous time), so every compiler uses it. Integer results
are identical.
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