Skip to content

Stable simd - #379

Open
Dr-Emann wants to merge 3 commits into
mainfrom
stable_simd
Open

Dr-Emann wants to merge 3 commits into
mainfrom
stable_simd

Conversation

@Dr-Emann

@Dr-Emann Dr-Emann commented Oct 9, 2026

Copy link
Copy Markdown
Member

This PR ports the experimental simd feature from nightly-only core::simd to fearless_simd.

Most of the changes come from adapting the existing algorithms to the new API: vector types now carry a generic parameter that we have to carry through operations, helpers, and visitors.

The generics enable compiling implementations for multiple SIMD levels. With std, runtime CPU detection selects the appropriate implementation on x86/x86-64, allowing a binary to use instructions beyond its compile-time target. no_std builds continue to use compile-time target features.

Also add tests comparing SIMD and scalar results, and updates CI to test SIMD on stable, with and without std.

Closes #373
Same as #378, but from a branch inside this repo so I can stack a little.

Open Questions

Should we enable simd by default?

Should we even have a simd feature (or, still have the feature, but have it do nothing, and always enable simd)?

@Dr-Emann
Dr-Emann added this pull request to stack #381 October 9, 2026 17:16
@Dr-Emann
Dr-Emann force-pushed the stable_simd branch 2 times, most recently from 11be30d to 253c2af Compare October 9, 2026 19:20
CI builds with `-C target-cpu=native`, and without `--target`, Cargo
applies `RUSTFLAGS` to proc macros too. The cached
`fearless_simd_macros` proc macro was built on a runner with more CPU
features than later runners which restored it from the cache, so rustc
crashed with SIGILL when it loaded the macro. Cached dependencies could
fail the same way at test time.

Add a hash of the target features `-C target-cpu=native` enables (from
`rustc --print cfg`) to the cache key, so a cache is only restored on
runners with the same target features. Unlike hashing all of
/proc/cpuinfo's flags, runners with different CPU models but the same
target features still share a cache.
The `simd` feature now builds on stable Rust. The vectorized array store
operations (or, and, xor, sub) are ported to fearless_simd with the same
algorithms:

fearless_simd needs either `std` or `libm`, so `libm` is enabled for
no_std builds; with `std` enabled, fearless_simd uses std instead.

Adds tests comparing each vectorized op against its scalar version at the
baseline and detected SIMD levels. CI now checks and tests `simd` on
stable, including no_std.
`255 - mask` and `mask ^ 0xFF` both invert a u8 bitmask; `!mask` says so
directly.
@emschwartz

Copy link
Copy Markdown

Measured this stack (#379 through #383, head fec473a) on a Zen 5 and read the generated x86 code, in case it helps review. Short version: it builds and passes tests on stable 1.98 and 1.99 with --features simd, merges cleanly together with #369, #375 and #376, and on balanced array containers & is about 8x faster and intersection_len about 30x faster than main. Two codegen observations below, in xor_impl and in the AVX2 or/xor bodies, that look fixable; no fix was tried here.

Numbers

EPYC 9845, -C target-cpu=x86-64-v3, thin LTO, 10 interleaved process pairs (fresh seed per pair), geometric mean of the per-pair ratio with 95% CI, A/A gate on the same box ±0.14%. Each row is main vs #383, &a & &b and a.intersection_len(&b).

shape & intersection_len
50k vs 50k random u32 in 0..1M (arrays of ~3.3k per container) 440 µs → 54 µs, −87.6% [−87.8, −87.5] 431 µs → 14 µs, −96.7% [−96.75, −96.72]
10 vs 4,000 values in one container 3.33 µs → 2.35 µs, −28.7% [−30.8, −26.6] 2.10 µs → 0.92 µs, −54.0% [−58.5, −48.9]

Workloads dominated by run and bitmap containers (chains of -=, iteration, contains, random inserts) are within ±5% of main, as expected since the stack only touches array containers.

What the binary does on Zen 5

From objdump -d of the timed binary and cargo asm --target x86_64-unknown-linux-gnu --target-cpu x86-64-v3 on a release build with --features simd.

  • Runtime dispatch reaches the AVX-512 level. That level is the SSE kernel in EVEX encoding: u16x8 in xmm throughout, compares into k masks, vpermb in place of vpshufb for the swizzle table. No zmm, no vpcompressw, no vp2intersect. Expected given the u16x8 design; noting it so nobody assumes AVX-512 adds a further win on top of AVX2 yet. A vpcompressw swizzle would remove the 4 KB table load, if fearless_simd exposes it.
  • pcmpistrm from Use PCMPISTRM for the intersection and difference comparison on SSE4.2+ in Stable Simd #380 is emitted at every level from SSE4.2 up, with a two-way branch to pcmpestrm when either chunk contains a zero lane.
  • Dispatch costs about 15 instructions per op (cached level check, closure packing, direct call). The visitor is fully inlined. No bounds checks or panics inside any vector loop.
Two codegen observations
  1. xor_impl: the v_max workaround does not take effect on x86. On both the AVX2 and AVX-512 bodies LLVM still extracts lanes 6 and 7 of v_max after the vpunpckhwd (vpextrw $7,%xmm6 / vpextrw $7,%xmm5) and re-inserts the same values at the top of the next iteration (vpinsrw $6 / vpinsrw $7) before the bitonic network. The values are what the unpack already produced, so the work is redundant, but the two vpinsrw sit on the loop-carried chain ahead of the 4-stage merge (about 9 cycles per 8 elements). or_impl has no such sequence, so comparing the two loops' asm shows it directly.
  2. AVX2 or/xor spill every iteration. Those bodies are inlined into bitor/bitxor, and because xmm registers are caller-saved across the possible Vec::reserve call in the visitor, the vector state is stored and reloaded each iteration (5 stores, 4 loads per 8 outputs, with v_max going through store-forwarding). The out-of-line AVX-512 copies keep everything in registers, so this affects AVX2-only hosts, not Zen 4/5. Reserving the output once before the loop, or keeping the reserve path out of line, would likely let the AVX2 loop stay in registers.

Authored by Claude (claude-fable-5-1) at Evan Schwartz's request.

@Dr-Emann

Copy link
Copy Markdown
Member Author

I reordered the bitonic merge call in xor to match or: passing the newly loaded vector as the second argument (which gets reversed) rather than the vector carried from the previous step, this both keeps the loop carried dependency chain shorter, and seems to also avoid pulling out individual lanes in the loop as well.

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.

Stable SIMD for array containers via std::arch + runtime detection?

2 participants