SIMD with Zig
openmymind.net
openmymind.net
https://github.com/ziglang/zig/issues/7702
I don't think anyone disagrees about the need for intrinsics. In fact, I have actually taken a crack at implementing the AVX512 intrinsics into the Zig compiler as builtin functions on my personal fork of the repo. But it is a non-trivial task - there are over 450 distinct instructions across the entire AVX512 feature set, and over 100 for AVX2. And I'm only focusing on support for the LLVM backend, which does the heavy lifting in the codegen phase. Getting the register allocation and instruction scheduling correct for all the intrinsics in the self hosted backend would involve a lot more work.
1: https://github.com/google/highway/blob/master/g3doc/quick_re...
I agree it would perhaps be possible to find better semantics for SIMD that kinda gloss over all the differences. That would be cleaner but require a lot of names. Well I suppose that's what Highway does, isn't it?
Thanks for your effort working on an implementation too. I am aware how large these instruction sets have gotten so I can at certainly imagine at least some of the effort of the undertaking.
But it is useful and given the peculiarities of those SIMD instructions, I am not convinced that it will ever be sufficient to use "vectorized" types + a few hints and let the compiler do the work. That would be nice though.
I understand the hesitation of a language design team to replicate the full intrinsics mess, they are probably hoping to find something better.
In the mean time we call still fallback to C to write SIMD heavy code.
Take a simple 4-wide dot product for example, on x86_64 you'd have 3-4 different implementations (SSE2, SSE3 w/ hadd, SSE4.2 w/ dpps). But the function itself is just a few clock cycles, and calling it via function pointer will eliminate any gains and you might as well compute it with a scalar loop at that point.
This is further compounded by inhibiting compiler optimizations. You can't use the dot product function in higher level code expecting it to be inlined and further optimized (which is really the key to performance) if it's behind a dynamic dispatch.
A sufficiently smart compiler could maybe propagate the dynamic dispatch above, so that all the dot products get inlined but all code using dot product would get emitted multiple times with different dot product implementations, with the dynamic dispatch only at the top level. This has a slight risk of combinatorial explosion, but there really aren't that many combinations of supported ISAs in real hardware out there.
Another option you can use without any special compiler support is to take all your performance sensitive parts and pack them into a shared object/dll, compile multiple versions with different compiler options and choose the correct dll at runtime. Or even build the entire executable a few times and have some kind of launcher pick the correct one.
I understand the desire for magic-indirection ergonomics; I just don't think the tradeoffs work out the same for code vs data.
It's a tiny bit harder to force an indirect call if that's desired for some reason; I think you'd need to write a slightly longer never inlined helper function to strip the constness from the pointer. It's doable though, just not directly provided by the language.
I appreciate that this doesn't fit Zig's call syntax, which is unlikely to change, so the long-term best case is probably some LSP marking based on the callee type.
1. IMO it'd be a bit more ziggish to branch at compile time. Zig has cross-compilation as a first-class feature, and you generally know the architecture you're targeting.
2. Whether you're branching at runtime or compile time, it'd be easy to build a vectored app with that behavior. The first thing that comes to mind is having code that's generic on the vector type (or bit width) and then just choosing which generic to instantiate in an inline for loop in your app's entrypoint.
3. A lot of the time you don't need that behavior. Select a vector width 8x too big, ensure your data is chunked into multiples of that, and rely on the compiler to break that down into a few instructions of the appropriate length. You can't effectively target a GPU that way, and it's not the same as hand-tuned assembly, but you get decent results on a vast array of problems.
I’ve seen people just compile for the lowest common denominator. E.g. faiss-cpu on PyPI doesn’t even include AVX2 and is slow as heck.
- You pay for it in build times, more IR, more optimizations.
- a lot of software is for servers, in that case you mostly know the arch before-hand. Multi-versioning is best for consumer software.
- The lowest spec computers, in consumer software, need the optimizations the most. And they don't have latest instructions.
- SIMD has biggest gains from memory optimizations, and those do not necessarily require the latest and greatest instructions.
But, this functionality is absolutely critical. It doesn't even have to be automatic. Just the ability to compile functions with certain ISA extensions enabled, and then only call them when the requisite CPU features are enabled is enough.
In a nutshell: https://github.com/BurntSushi/memchr/blob/8037d11b4357b0f07b...
const std = @import("std");
fn f(comptime width: comptime_int, value: i32) i32 {
const v = @splat(width, @as(i32, value));
return @reduce(.Add, v);
}
pub fn main() !void {
std.debug.print("1={d}\n", .{f(1, 5)});
std.debug.print("2={d}\n", .{f(2, 5)});
std.debug.print("4={d}\n", .{f(4, 5)});
}
Here it creates 3 different versions of the function f at compile time, and then calls them each in succession. Running it prints: 1=5
2=10
4=20
In practice you'd need to set up a dispatch that chooses the function based on the hardware, and ensure that zig/LLVM are actually using the full width of the vectors when compiling.``` { false, false, false, false, true, false, false, true } ```
It will give an int with the bits: `0b0000_1001`
You can then do `@clz` to get the index of the first set bit.
That would save you the `@reduce` and `@splat`. I don't see how to access move mask from Zig, however.
Note that the standard library has `std.zig.firstIndexOfValue`, which does what you have in the post, basically. And there is `std.zig.firstTrue`, which does the the same thing as `@clz` in this case, but I don't know what kind of assembly it will generate.
Bools in a vector each only occupy one bit. You can @bitCast an N element bool vector directly to a uN integer and it will generate movemask on x86.
@ptrCast(*const u64, &my_bool_vec).*> You need to benchmark, test and tweak in order to figure out what works best. Benchmarking is particularly important because, unless you're dealing with large data or very hot code, there's a good chance that effort won't yield measurable benefits.
How to benchmark something like this sounds like it could become a pretty good article in its own right.
Usually, you can run something like perf or callgrind, with instruction level profiling, and you will get a good idea. Its not benchmarking in the traditional sense, but it has a similar result.
SIMD can make your code faster, or slower - profiling is a great way to tell exactly how much faster or slower.
I had to go look up "std.mem.indexOfSclar" in the source [1] since my, admittedly rather butt-hurt, feeling is that I can't guess Zig naming. It's supposed to be "indexOfScalar" which of course makes 100% sense.
[1]: https://github.com/ziglang/zig/blob/master/lib/std/mem.zig#L...