public

fearless_simd 1.0, Measured in a Browser

read-only

AI authored to showcase ironpad capabilities.

Introduction

Linebender has released fearless_simd 1.0, eight years after the first prototype. The pitch is SIMD on stable Rust without unsafe in your code: vector types you can write once against the machine's native width, a dispatch! macro that picks the best instruction set the machine supports (SSE2 through AVX-512 on x86, detected at runtime; NEON on ARM; simd128 on WebAssembly), and a safe route down to raw intrinsics for whatever the portable API does not cover.

WebAssembly has no runtime feature detection, so in a browser dispatch! has exactly one level to choose from and the x86 multiversioning never runs. The cells below measure whether the abstraction costs anything against std::simd, what a build without the simd128 flag loses, what the crate's precise spelling of an operation costs next to its fast one, and what dropping to a raw intrinsic looks like.

The dot product and Mandelbrot kernels are borrowed from Four Lanes Wide. Every timing is measured live in your browser, and the figures the prose quotes from the tables come from the run that captured this page, so treat them as directional.

One level to choose from

On x86, Level::new() runs CPUID once, caches the answer, and dispatch! jumps to the SSE2, SSE4.2, AVX2 or AVX-512 copy of your function. The same generic function sees S::f32s as f32x4 under SSE, f32x8 under AVX2 and f32x16 under AVX-512.

On WebAssembly the compiler decides. A module built with simd128 gets WasmSimd128, a module built without it gets Fallback, and neither can discover the other at runtime. The crate's advice is to ship two bundles and choose between them in JavaScript with wasm-feature-detect. ironpad compiles each cell once, with the flag on whenever the cell's source names a SIMD crate:

[1]Which level did this cell get?
1 panel
Saved output from the author's last run. Press Run to execute live in your browser.
questionanswer
Level::new()WasmSimd128(WasmSimd128 { _private: () })
is_fallback()false
native f32 lanes (S::f32s::LEN)4
compiled with simd128true

Against std::simd

The dot product from Four Lanes Wide, over the same two 16K-element buffers, ten thousand times. The std::simd versions keep Four Lanes Wide's loops, moved into functions; the fearless_simd versions are the same loops written against a generic S: Simd and called through dispatch!.

f32x16 is a fixed 16-lane type, which on a 128-bit machine the crate lowers to four v128 registers, i.e., the four-accumulator version Four Lanes Wide wrote out by hand. The last row runs the native-width kernel on Level::fallback(), the scalar backend the crate exposes for testing, inside this same simd128 build.

[2]Dot product, six ways
1 panel
Saved output from the author's last run. Press Run to execute live in your browser.
kerneltime (ms)vs scalardot
scalar2831.0x1862960
std::simd f32x4, one accumulator1511.9x1862960
std::simd f32x4, four accumulators575.0x1862960
fearless_simd native width (S::f32s)1581.8x1862960
fearless_simd f32x16575.0x1862960
fearless_simd native, forced Fallback1531.8x1862960

On the capture run the native-width kernel took 158 ms against std::simd's one accumulator at 151, and f32x16 took 57 ms, the same as the hand-written four accumulators; across repeated runs neither library was consistently ahead. Each fearless_simd kernel carries #[simd], which forces the body to inline into a function compiled with the target features enabled.

Changing the type from the native width to f32x16 bought 2.8x, because the adds now run as four independent chains instead of one. The same f32x16 lowers to two registers under AVX2 and one under AVX-512.

The forced-Fallback row ran as fast as the SIMD kernel (153 ms against 158), and the crate documents why: the fallback branch is still compiled with the "ambient" target features, so LLVM is free to autovectorize it. Timing Level::fallback() inside a simd128 build therefore does not predict a browser without simd128; the section after the Mandelbrot measures that directly.

Four Lanes Wide reports 4.6x for one accumulator over scalar, where this page measures 1.9x. Its loops run inline over buffers the compiler can see, while these take their inputs through std::hint::black_box, because during development LLVM hoisted one of these pure kernels out of its loop and reported 10,000 calls in 0 ms. In a separate probe, hiding the inputs made the one-accumulator kernel 2.5x slower and left the scalar loop where it was. Which property of the visible buffers LLVM exploits was not established. Both libraries go through the same black_box in every table here.

Mandelbrot

The dot product loads two floats for every multiply and add it does. The Mandelbrot loop runs up to 300 iterations of dependent multiplies and adds per pixel with no loads at all.

Below, the Four Lanes Wide renderer next to a fearless_simd port of it. The cell asserts the two renders agree pixel for pixel.

While building this page, single timed renders came in 2.5x to 3.9x slower than the best of five, even after an untimed warm-up call. The cause was not isolated. Both Mandelbrot timings on the page take the best of five for that reason, and the dot product and Horner benchmarks make one untimed call first and then time hundreds or thousands of repetitions.

[3]Mandelbrot, std::simd against fearless_simd
1 panel
Saved output from the author's last run. Press Run to execute live in your browser.
render (800 x 600, 300 iters, best of 5)time (ms)
std::simd f32x424
fearless_simd f32x422

Without the flag

fearless_simd selects its WebAssembly backend with cfg(target_feature = "simd128"). Build without that flag and the crate compiles Fallback instead, without an error or a warning, and every vector op becomes a small array loop. A plain cargo build --target wasm32-unknown-unknown does exactly this, because the flag is off by default.

ironpad shipped this bug until this notebook was written. Its SIMD detection matched std::simd, core::simd and std::arch::wasm32 in a cell's source, so a cell using fearless_simd compiled the scalar fallback. The detection now also matches fearless_simd, wide:: and pulp::, and the regression test builds a cell that calls Level::as_wasm_simd128, a method compiled only when the dependency itself saw the flag.

The cell below renames the crate to lanes in its Cargo.toml, so its source matches none of those patterns and ironpad builds it without the flag. The kernels are the ones above, with #[inline(always)], the crate's documented manual equivalent, in place of #[simd].

[4]The same kernels, built without simd128
2 panels
Saved output from the author's last run. Press Run to execute live in your browser.

Level::new() is Fallback(Fallback { _private: () }), and the native f32 vector still reports 4 lanes.

kernel, built without simd128time (ms)
dot product, native width210
dot product, f32x1663
Mandelbrot, best of 584

Against the flag-on cells above, on the capture run:

kernelwith simd128withoutcost of the missing flag
Mandelbrot22 ms84 ms3.8x
dot product, native width158 ms210 ms1.3x
dot product, f32x1657 ms63 ms~1.1x, inside run-to-run spread

The Mandelbrot lost 3.8x of a possible 4x. The f32x16 dot product lost next to nothing: without simd128 its sixteen lanes compile to sixteen scalar accumulators, and across the runs made while building this page those landed between 54 and 65 ms, against 53 to 60 ms for four v128 registers.

That row is why a WebAssembly build should assert !Level::new().is_fallback() in its tests, since a benchmark of that kernel would pass with the flag missing.

Fast and precise

Where hardware disagrees on a result, fearless_simd offers two spellings of the operation: max and max_precise, reduce_min and reduce_min_precise, mul_add and mul_add_precise, etc. The fast one uses whatever the current instruction set does, and the precise one returns the same bits on every backend.

For mul_add, the disagreement is whether the hardware has a fused multiply-add at all. On AVX2 and NEON the fast mul_add is one fused instruction that rounds once. On SSE4.2 and on baseline WebAssembly it is a multiply followed by an add, rounded twice, so the same call returns different bits on an M-series Mac and in this tab. mul_add_precise always returns the fused answer, and where the instruction does not exist the crate computes it in software: each lane goes through f64 with an error-correction step.

(1 + ε)(1 − ε) is 1 − ε², which an f32 cannot represent, so a rounded product is exactly 1.0 and subtracting 1 leaves nothing; a fused multiply-add rounds once, at the end, and keeps the −ε². The timing half of the cell runs a degree-8 polynomial by Horner's rule, eight multiply-adds per element, through each spelling.

[5]mul_add against mul_add_precise
2 panels
Saved output from the author's last run. Press Run to execute live in your browser.
(1 + ε)(1 − ε) − 1result
mul_add0e0
mul_add_precise-1.4210855e-14
f32::mul_add (std, scalar)-1.4210855e-14
exact: −ε²-1.4210854715202004e-14
Horner, degree 8, 64K floats x 500time (ms)vs mul_add
mul_add541.0x
mul_add_precise4618.5x slower
f32::mul_add, one lane87416.2x slower

The fast spelling returned 0 where the exact answer is −ε² ≈ −1.42 × 10⁻¹⁴. The precise spelling agreed with std's scalar f32::mul_add to the last bit, at 8.5x the cost of the fast one on the capture run. The one-lane std version, which also has no instruction to lean on here, took 16.2x.

Default to the fast spelling, then, and reach for _precise where the bits have to match across machines, e.g., a deterministic simulation, a lockstep multiplayer game, or golden-file tests that run in CI on one architecture and in production on another.

Dropping to intrinsics

WebAssembly has an instruction fearless_simd does not wrap: i16x8.q15mulr_sat_s, a rounding, saturating multiply of Q15 fixed-point numbers. kernel! wraps a function over raw v128 values so that calling it requires the WasmSimd128 token, and conversions between the crate's i16x8 and v128 are plain into() and simd_into().

WebAssembly's arithmetic intrinsics are already safe functions in core::arch::wasm32, with or without the flag; the unsafe ones are the raw-pointer loads and stores such as v128_load. fearless_simd's from_slice and to_array replace those with safe slice operations. On x86 and ARM, kernel! also makes #[target_feature] functions callable without unsafe.

[6]A Q15 multiply through kernel!
1 panel
Saved output from the author's last run. Press Run to execute live in your browser.
abrawa x bmatches scalar
0.50000.500081920.2500yes
-0.50000.5000-8192-0.2500yes
1.00001.0000327660.9999yes
-1.0000-1.0000327671.0000yes
0.3767-0.0098-121-0.0037yes
0.00000.000000.0000yes
-1.00001.0000-32767-1.0000yes
0.70710.7071163830.5000yes

Field notes

  • Stable versus nightly. std::simd still needs #![feature(portable_simd)] and a nightly compiler. fearless_simd declares a minimum of stable Rust 1.89, and it matched std::simd on both kernels here.
  • Not measured here. The runtime dispatch across SSE2, SSE4.2, AVX2 and AVX-512 never ran here, and neither did relaxed SIMD, since ironpad builds baseline simd128 only. Multiversioning is the feature that most distinguishes the crate, and measuring it needs a native benchmark.
  • Measurement limits. Stopwatch reads Date.now(), which ticks in whole milliseconds, so every loop here is sized to run for tens of milliseconds or more. The captured figures come from one headless Chromium on one machine.
  • The import collision. fearless_simd::prelude::* and std::simd::prelude::* both export Simd and Select, so a file that glob-imports both fails to compile. The cells here import the std::simd traits as _.