SIMD for Collision
32 points by mikejsavage
32 points by mikejsavage
SIMD is surprisingly easy to reason about for tight tasks like this and more software people should be utilizing it. I think people get hung up on the crazy (and genuinely impressive) SIMD projects like simdutf and simdjson, which are head-spinning in complexity.
But basic "process N bytes at a time" SIMD that could speed up many ordinary loops are fairly simple! They all follow the same pattern:
Here is a simple example. I have a byte string that I want to keep consuming until I see a value less than 0xF (a control character limit). The naive loop:
// Loop while we see codepoints above 0xF
while (end < cps.len and cps[end] > 0xF) {
end += 1;
}
And here is the generic vector version (no CPU-specific intrinsics):
// If the target CPU doesn't have a vector size for u32 just fallback to
// scalar. This has some CPU detection and hardcoding so we pick
// the proper vector width for the hardware.
if (simd.lanes(u32)) |lanes| {
// A vector containing `lanes` independent `u32` values. Basically a chunk of u32!
const V = @Vector(lanes, u32);
// Broadcast 0xF into every lane so one vector comparison checks every value.
const threshold: V = @splat(0xF);
// Only enter the SIMD loop when a complete vector remains in the input.
while (end + lanes <= cps.len) {
// Load the next `lanes` codepoints into a single SIMD vector.
const values: V = cps[end..][0..lanes].*;
// Compare every codepoint against 0xF in parallel, producing one boolean per lane.
const greater_than_threshold = values > threshold;
// When every lane is greater than 0xF, the entire vector can be skipped.
if (@reduce(.And, greater_than_threshold)) {
end += lanes;
continue;
}
// Convert the per-lane booleans into a bit mask: 1 means the value was > 0xF.
const mask: std.meta.Int(.unsigned, lanes) =
@bitCast(greater_than_threshold);
// Invert the mask so failing lanes become 1, then find the first failing lane.
end += @ctz(~mask);
// We found the first value <= 0xF, so the scan is complete.
break :scan;
}
}
// Finish the partial vector-sized tail, or do the entire scan when SIMD is unavailable.
// It's just our normal scalar loop!
while (end < cps.len and cps[end] > 0xF) {
end += 1;
}
This gets you straight throughput speedup of anywhere from 2x to 16x depending on the hardware. This is real, because its from my actual project.
I realize that it looks like a LOT more code, but its code following a really specific pattern that you get used to quickly (the steps outlined above). And, the comments and whitespace add a lot of noise. :)
Why can’t the compiler just do this through auto-vectorization? Sometimes it can! Compilers are quite good at vectorizing simple, regular arithmetic loops. But you still can't reliably expect them to discover more complicated transformations like this one, particularly when the loop contains an early exit and the desired implementation involves a comparison mask, a reduction, and locating the first failing lane. More details here: https://llvm.org/docs/Vectorizers.html (always know your compilers!)
You could say “compilers aren't good enough for now,” but auto-vectorization has been an active compiler research problem for decades, and recent research still begins from the observation that production compilers frequently miss vectorization opportunities. (Ref from 2024: https://arxiv.org/abs/2406.04693)
Once you understand the basic SIMD pattern, code like this becomes nearly as natural as writing the scalar loop... more engineers should know how to do this and more languages should provide the tools to do it.
EDIT: I expanded this comment into a full more detailed blog post: https://mitchellh.com/writing/everyone-should-know-simd
Does making good use of SIMD require struct-of-arrays rather than array-of-structs? Presumably array-of-structs would require a lot of additional copying/masking which would negate the SIMD benefit? Also, how do you program with a single SIMD interface? I'm of the impression that different CPUs (even within the same architecture) have different SIMD instructions--does the runtime have to bundle implementations that use every instruction supported on the target architecture along with polyfills for CPUs without SIMD at all? Or do you just target one particular SIMD instruction set?
Does making good use of SIMD require struct-of-arrays rather than array-of-structs?
Um, it generally requires helpful data layouts, such as packed data. So SoA helps but not necessarily required depending on what you're processing. Like, if you're processing strings, they're already packed...
Also, how do you program with a single SIMD interface?
My example uses Zig intrinsics which are great. Ghostty also makes heavy use of Highway: https://github.com/google/highway Zig intrinsics are good for when your self-compiling or your baseline CPU is rich enough you can assume. Highway is for runtime dispatch (multi-module compilation and runtime CPU fingerprinting + dispatch).
Um, it generally requires helpful data layouts, such as packed data. So SoA helps but not necessarily required depending on what you're processing. Like, if you're processing strings, they're already packed...
Ah, this makes sense to me. Thanks for helping me understand.
My example uses Zig intrinsics which are great.
Hmm, I don't know much about Zig. I'll have to look into its intrinsics and highway.
but auto-vectorization has been an active compiler research problem for decades, and recent research still begins from the observation that production compilers frequently miss vectorization opportunities. (Ref from 2024: https://arxiv.org/abs/2406.04693)
How well deployed is the research of late? Can I reasonably expect most compilers will be using it? It's been quite some time since I payed attention to this area, but my recollection was that work in auto-vectorization in particular had a tendency to produce research POCs, sometimes hit a fortran compiler, but often didn't get implemented at all in anything more mainstream.
Auto-vectorization is present in both GCC and LLVM, e.g. https://llvm.org/docs/Vectorizers.html
And it is good at finding the simplest cases, which are indeed common! A recent LLVM21 upstream bug broken auto-vectorization for my project and I was able to identify 3 separate areas that I was implicitly relying on auto-vectorization (without realizing it). The result was my benchmarks were 70% slower (almost 2x! from an implicit compiler optimization!). I didn't even realize I was relying on it, so I hand vectorized those code paths and gained it all back.
But it really only works for the simplest cases.