SIMD Matters: Graph Coloring
box2d.org
box2d.org
<TANGENT> This hits me, like a ton of bricks, as one of the most elegant ways to describe why I add 2 phases of clocking ("like colors on a chessboard" is the phrase I've been using) to my BitGrid[1] hobby project.
I wonder what other classes of problems this could solve. This feels oddly parallel, like a mapping of the Langlands program into computer science.[2]
- If they're sending and receiving along diagonals, aren't those actually bishop-neighbors, not rook-neighbors?
- Think you meant Crypt of the Necro _dancer_
- The grid in the background makes it hard to discern whether they also send to rook-neighbors
- Secret 4th nit - Wait, are the cells just rotated 45 in the diagram and not in the prose?
There are 2 grids, so it depends on your frame of reference. The node connections are oriented 90 degrees to the shape of the node in the picture, which means rook neighbors is accurate relative to the nodes. The background is off by 45 degrees, so node connections relative to the background grid are diagonal, or bishop, moves.
This has been my experience often times I misunderstood how much can be gained by using SIMD, and preparing the data to be "eaten" by SIMD instructions is not trivial. I have many times attempted to use just to profile and see it didn't improve things at all and made the code really hard to understand.
Kudos for Erin here this is really hard work and it's great it paid off well and gave good results here!
I've always found my best experiences with SIMD to be one level removed from the actual scary bits. I.e., using primitives from libraries which are inherently optimized.
My favorite SIMD abstraction right now is Vector<T> in .NET:
https://learn.microsoft.com/en-us/dotnet/api/system.numerics...
If you're worried you'd get that one wrong too, you can go up another level of abstraction and use something like Matrix4x4 and its members which are also optimized.
The main problem with writing SIMD code for the first time is it's a learning curve to understand that the performance improvement doesn't come from just performing n element operations per single instruction but also reducing the book-keeping that usually comes per element per loop iteration like conditional branches to terminate the loop, branchy element selects over branchless shuffles in vectors, loading more data at a time, etc.
Which is why many first time attempts lead to wrong impression that vectorization is hard, while the truth is they just stumble into known "don't do that, also do less" traps like needlessly spilling vectors or writing to intermediate buffers instead of directly, modifying individual vector elements or even iterating them, avoiding actual vector operations.
There's a new-ish guide on vectorization if you're interested: https://github.com/dotnet/runtime/blob/main/docs/coding-guid...
Who said SQL and columnar/row storage models? ;)
Something like LINQ can do the job as well.
I'd think that a fundamental problem is memory I/O bandwidth, and (depending on cache policy) poor cache hit ratios.
Is that not the case?
A typical example would be: you have an array of objects of constant size, and you’re reading a double field from a constant offset within each object. The hardware prefetcher will “recognize” this access pattern and prefetch that offset every sizeof(obj) bytes.
The major downsides (vs. a struct-of-arrays design with full spatial locality) are:
1. Every prefetch pulls a full cache line, but the cache line will include data you don’t need. In this example, every cache line might have 64 bytes of data, but you only needed the one double field (8 bytes) - the rest is not useful. If you were iterating over an array of doubles you could have pulled 8 double fields in a single cache line.
2. Specific performance is hardware-dependent, so it’s hard to guarantee performance on e.g. low-end cores, short loops, or unusually long strides.
You seem to be assuming that a vectorized loop over an array of structs only ever uses one element from the struct and ignores the rest. In that case, then yes, you will get some bandwidth problems, but modern hardware prefetchers are so good that a vectorized loop that only looks at each Nth byte or whatever is actually much faster than one might think. I'll also note that the serial (non-SIMD) case also suffers here, but often not quite so badly as the SIMD case suffers.
However, there's lots of cases where you want the whole struct. E.g. complex numbers are typically a struct of two real numbers, and SIMD arithmetic on vectors of structs of complex numbers is able to work just fine with the full theoretical SIMD performance improvement.
That is the worst case scenario of my concern with memory bandwidth.
More generally, I'd imagine that any good roofline analysis would need to consider this data-layout issue.
> but modern hardware prefetchers are so good that a vectorized loop that only looks at each Nth byte or whatever is actually much faster than one might think.
I believe you. I wasn't really thinking about how efficiently the processor can pull the correct bytes into the register lanes. I was just focusing on the fundamental memory <--> CPU xfer part of the process.
TL;DR: I'm trying to get better at using roofline analysis in my optimization efforts.
https://documentation-service.arm.com/static/6530e5163f12c06... (PDF)
Last time I tried using gathers on AVX2, performance was comparable to doing scalar loads.
Gathers on AVX2 used to be problematic, but assume it shouldn't be the case today especially if the lane-crossing is minimal? (if you do know, please share!)
When using SIMD you must either use SoA or AoSoA for optimal performance. You can sometimes use AoS if you have a special hand coded swizzle loader for the format.
You can write them yourself also but I'd add a verifier that checks the output with scalar code as it can be tricky to get correct.
Intel had an article up for the 3x8 transpose, but it seems to no longer exist so i'll just post the psuedo code
//xyz -> xxx
void swizzle3_AoS_to_SoA(v8float &x, v8float &y, v8float &z) {
v8float m14 = interleave_low_high<1, 2>(x, z); //swap low/high 128 bits
v8float m03 = blend<0, 0, 0, 0, 1, 1, 1, 1>(x, y); //_mm256_blend_ps 1 cycle
v8float m25 = blend<0, 0, 0, 0, 1, 1, 1, 1>(y, z);
//shuffles are all 1 cycle
__m256 xy = _mm256_shuffle_ps(m14, m25, _MM_SHUFFLE(2, 1, 3, 2)); // upper x's and y's
__m256 yz = _mm256_shuffle_ps(m03, m14, _MM_SHUFFLE(1, 0, 2, 1)); // lower y's and z's
v8float xo = _mm256_shuffle_ps(m03, xy, _MM_SHUFFLE(2, 0, 3, 0));
v8float yo = _mm256_shuffle_ps(yz, xy, _MM_SHUFFLE(3, 1, 2, 0));
v8float zo = _mm256_shuffle_ps(yz, m25, _MM_SHUFFLE(3, 0, 3, 1));
x = xo;
y = yo;
z = zo;
}Basically the most popular imperative paradigms are just ill-suited to making the most of SIMD hardware. They're designed with other goals in mind.
I will keep this example in mind the next time somebody trots out the line that you should just trust the compiler.
I saw a small typo:
// wide float
typedef b2FloatW __m128;
The `typedef` is backwards, the alias and the underlying type name are in the wrong order and need to be swapped around.The NP-complete problems are for finding the optimal number of colors, or determining if the graph can be colored using a set small number of colors.
Erin's solution aims for a small number of colors, but doesn't try to be the optimal number.
For example, I just made a little example on my desktop where I summed up 256 random Float32 numbers, and doing it in serial takes around 152 nanoseconds, whereas doing it with SIMD took just 10 nanoseconds. Doing the exact same thing with my GPU took 20 microseconds, so 2000x slower:
julia> using CUDA, SIMD, BenchmarkTools
julia> function vsum(::Type{Vec{N, T}}, v::Vector{T}) where {N, T}
s = Vec{N, T}(0)
lane = VecRange{N}(0)
for i ∈ 1:N:length(v)
s += v[lane + i]
end
sum(s)
end;
julia> let L = 256
print("Serial benchmark: "); @btime vsum(Vec{1, Float32}, v) setup=(v=rand(Float32, $L))
print("SIMD benchmark: "); @btime vsum(Vec{16, Float32}, v) setup=(v=rand(Float32, $L))
print("GPU benchmark: "); @btime sum(v) setup=(v=CUDA.rand($L))
end;
Serial benchmark: 152.239 ns (0 allocations: 0 bytes)
SIMD benchmark: 10.359 ns (0 allocations: 0 bytes)
GPU benchmark: 19.917 μs (56 allocations: 1.47 KiB)
The reason for that is simply that it just takes that long to send data back and forth to the GPU and launch a kernel. Almost none of that time was actually spent doing the computation. E.g. here's what that benchmark looks like if instead I have 256^2 numbers: julia> let L = 256^2
print("Serial benchmark: "); @btime vsum(Vec{1, Float32}, v) setup=(v=rand(Float32, $L))
print("SIMD benchmark: "); @btime vsum(Vec{16, Float32}, v) setup=(v=rand(Float32, $L))
print("GPU benchmark: "); @btime sum(v) setup=(v=CUDA.rand($L))
end;
Serial benchmark: 42.370 μs (0 allocations: 0 bytes)
SIMD benchmark: 2.669 μs (0 allocations: 0 bytes)
GPU benchmark: 27.592 μs (112 allocations: 2.97 KiB)
so we're now at the point where the GPU is faster than serial, but still slower than SIMD. If we go up to 256^3 numbers, now we're able to see a convincing advantage for the GPU: julia> let L = 256^3
print("Serial benchmark: "); @btime vsum(Vec{1, Float32}, v) setup=(v=rand(Float32, $L))
print("SIMD benchmark: "); @btime vsum(Vec{16, Float32}, v) setup=(v=rand(Float32, $L))
print("GPU benchmark: "); @btime sum(v) setup=(v=CUDA.rand($L))
end;
Serial benchmark: 11.024 ms (0 allocations: 0 bytes)
SIMD benchmark: 2.061 ms (0 allocations: 0 bytes)
GPU benchmark: 353.119 μs (113 allocations: 2.98 KiB)
So the lesson here is that GPUs are only worth it if you actually have enough data to saturate the GPU, but otherwise you're way better off using SIMD.GPUs are also just generally a lot more limiting than SIMD in many other ways.
> GPUs are also just generally a lot more limiting than SIMD in many other ways.
What do you mean? (besides things like CUDA being available only on Nvidia/fragmentation issues.)
* Float64 math is typically around 30x slower than Float32 math on "consumer-grade" GPUs due to an arbitrary limitation to stop people from using consumer grade chips for "workstation" purposes. This turns out to not be a big deal for things like machine learning, but lots of computational processes actually are rather sensitive to rounding errors and benefit a lot from using 64 bit numbers, which is very slow on GPUs.
* Writing GPU specific functions can be quite labour intensive compared to writing CPU code. Julia's CUDA.jl and KernelAbstractions.jl packages does make a lot of things quite a bit nicer than in most languages, but it's still a lot of work to write good GPU code.
* Profiling and understanding the performance of GPU programs is typically a lot more complicated than CPU programs (even if there are some great tools for it!) because the performance model is just fundamentally more complex with more stuff going on and more random pitfalls and gotchas.
EDIT: one more big thing is also that at least for AAA games you want to keep the GPU doing graphics so it looks good. You usually never have GPU cycles to spare.
I don't understand this statement at the end of the article? Can anyone explain? TIA.
The instructions are still there even in 64-bit long mode, they use their own registers, and there are enough idiosyncrasies (80-bit extended-double precision, stack-based operations, etc.) that I would expect it to be easier to just include a dedicated scalar x87 FPU than try to shoehorn x87 compatibility into the SIMD units.
float my_func(float lhs, float rhs) {
return 2.0f * lhs - 3.0f * rhs;
}
Becomes: my_func(float, float):
addss xmm0, xmm0
mulss xmm1, DWORD PTR .LC0[rip]
subss xmm0, xmm1
ret
(addss, mulss and subss are SSE2 instructions.)Intuitively, this feels like a narrower version of using Z-order curve.
Caveats: my knowledge is mostly theoretical (eg proving np-hardness or algorithm existence results) but I'm very good at thinking algorithmically. I have only hobbyist programming skills but I am a fast learner. Thanks!
So yes, I mostly concur. Compiler algorithms are usually graph algorithms.
That, and pretty much anything (co)authored by Robert E. Tarjan. I'm serious: https://github.com/search?q=repo%3Allvm%2Fllvm-project%20tar...
There's a good recent survey of the three workhorses: "We survey three algorithms that use depth-first search to find the strong components of a directed graph in linear time: (1) Tarjan's algorithm; (2) a cycle-finding algorithm; and (3) a bidirectional search algorithm."
Finding Strong Components Using Depth-First Search Robert E. Tarjan, Uri Zwick https://arxiv.org/abs/2201.07197
I was about this surprised when I made a jupyter notebook with a few gigs of numbers shuffled around and xgboosted and after I was done prototyping on an M1 Air and ran it on my serious box (a 12700k) it was actually slower, and noticeably.
And for 80% of the cases by the point there is enough vectorizable data for a programmer to look into simd, a gpu can provide 1000%+ of perf AND a certain level of portability.
So right now simd is a niche tool for super low-level things: certain decompression algos, bits of math here and there, solvers, etc.
And it also takes a lot of space on your cpu die. Like, A LOT.
Being 'careful how you write your code' is putting it mildly. IME you have to hold the compiler's hand at every step. Using 'bool' instead of 'int' can make gcc give up on autovectorisation, for example.
You need to use intrinsics, or a wrapper lib around them, if you want to be sure that SIMD is being used.
GPU perf/TCO is not always better than CPU, if you can even get enough GPUs, and the latency is also a concern and inducement to do everything on the GPU, which can be limiting.
It does not: https://github.com/bepu/bepuphysics2/blob/master/BepuUtiliti... (in this case, even if you use width-specific Vector128/256/512 - compiler will unroll operations on them into respective 256x2/128x4/128x2 if the desired width is not supported. Unfortunately .NET does not deal as well with fallbacks for horizontal reductions/shuffles/etc. on such vectors but it’s easily fixable with a few helpers)
Naturally, there are other languages that offer similar experience like Zig or Swift.
Moreover, sometimes just targeting one specific width is already good enough (like 128b).
Also you can’t (easily) go to GPU for most tasks SIMD is used in CPUs today. Good luck parsing HTTP headers with that.
Relying GPU only makes sense in a handful of context, e.g. “computing stuff fast is my core task” or “this will be deployed on beefy workstations”. SIMD addresses all the remaining cases and will give benefits to virtually everyone using your software; I'm sure one could implement a GPU-based JSON parsing library that would blow json-simd out of the water, but I'm not going to deploy GTX1060 on my cloud machines to enjoy it.
The bigger problem is most modern ISAs just aren't very nice to program in. Modern takes like SVE/RVV/AVX-512 are all way better here and much easier to write and think about because their instructions are much more general and have fewer edge cases.
> And it also takes a lot of space on your cpu die. Like, A LOT.
No, it doesn't (what would even be the "right" amount of die space?) But even if it did that would not make it a waste of space. Using up some amount of die space may result in huge performance gains for some subset of floating-point tasks that can't be achieved in any other way. For example software cryptography massively relies on these units, because the benefit is like multiple orders of magnitude; even if that's taking up 10% of die space for only 0.1% of software, that software has disproportionate impact -- if you took away that functional unit, then you may have a huge net decrease in efficiency overall, meaning you need more overall processors to handle the same workload from before.
The other problem with simd is that in modern cpu-centric languages it often requires a rewrite for every vector width.
Nope, you can use many existing libraries that present the same interface for all sizes, or write your own(which is what I did). And for 80% of the cases by the point there is enough vectorizable data for a programmer to look into simd, a gpu can provide 1000%+ of perf AND a certain level of portability.
Transfer latency & bandwidth to GPU is horrible, just utterly horrible. And GPU to CPU perf dif is more like 5x, and in games etc the GPU is already nearly maxed out And it also takes a lot of space on your cpu die. Like, A LOT.
Relative to the performance it can offer it is a very small area. The gains in the article are small compared to what I see, probably the author is new to SIMD.*