Improving performance with SIMD intrinsics in three use cases
stackoverflow.blog
stackoverflow.blog
y[n] = (x[n] + x[n-1]) / 2 + y[n-1]
Which will quickly show you that you can't just replace the numeric operations with their intrinsic counterparts and get speed up (and if you did, you may wind up with incorrect code!).It also shows you that sometimes more is less. To vectorize that code you wind up doing more multiplication than you would normally require for the scalar, and depending on the conditions where you execute it, it might not be able to improve over the scalar implementation.
There are more formal methods for converting these kinds of operations (linear, time-invariant recursive systems/filters) to vector equivalents using state space matrix forms, but then you start having to benchmark whether or not you get any benefits.
y[n] = (x[n] + x[n-1]) / 2
y = prefixSum(y);
I'm pretty sure prefixsum over Y would solve the problem (see: https://en.wikipedia.org/wiki/Prefix_sum#Algorithm_1:_Shorte... for the Vectorized solution to prefix sum).Without the addition of y[n-1], the first line is now trivially parallelizable by most auto-vectorizers. The prefix-sum probably needs to be written with intrinsics by hand, but once written it generalizes to many operations. (Prefix-max, prefix-min, prefix-XOR).
"Prefix-sum" is sometimes called "scan" in the literature (https://developer.nvidia.com/gpugems/gpugems3/part-vi-gpu-co...)
-----------
EDIT: A surprising number of "recursive" definitions can become a prefix-operation (or scan). Associativity is the main requirement. (Note that floating-points are not associative: error builds up non-associatively). So you'd need to redo your error analysis if these were floats.
It’s not that trivial to vectorize, would be something like this for SSE:
const __m128 x = _mm_loadu_ps( px );
// Rotate the lanes
__m128 xPrev = _mm_shuffle_ps( x, x, _MM_SHUFFLE( 2, 1, 0, 3 ) );
// Insert the highest lane of previousStepX into lowest lane of xPrev
// The IMM number means extract lane 3, insert into lane 0, don't zero out any lanes
xPrev = _mm_insert_ps( xPrev, previousStepX, 0b11000000 );
const __m128 halfSum = _mm_mul_ps( _mm_add_ps( x, xPrev ), _mm_set1_ps( 0.5f ) );
previousStepX = x;
Which compiler do you think would do that automatically? const __m128 x = _mm_loadu_ps( px );
const __m128 xPrev = _mm_loadu_ps( px + 4 );
_mm_loadu_ps: load-unaligned. There's no requirement for addresses to load/unload. There are probably penalties for loading across a cache line, but its perfectly valid to do this.-----------
> Which compiler do you think would do that automatically?
GCC simply does the unaligned load: https://godbolt.org/z/2HwYMi
Clang does the shuffle successfully: https://godbolt.org/z/Y3hbQn
The shuffle is probably optimal... I'm surprised CLang is good enough to recognize the opportunity. But simply doing 2x loads from memory is probably fine, especially because the 2nd load is guaranteed to be from L1 cache. Besides, assuming 128-bit vectors, 2x 128-bit vectorized loads every 4-elements (averaging 1/2 a load per element) is still superior to scalar code (1-load per element).
Honestly, Clang surprised me by generating the superior shuffle code. I was expecting both GCC and Clang to generate the suboptimal (but simpler) unaligned loads.
To half the count of RAM access instructions. Even L1D cache hit is still slower than these two instructions for shuffle and insert, both are quite fast, they deliver the result on the next cycle.
> GCC simply does the unaligned load
Right, that’s twice as many loads as necessary.
> Clang does the shuffle successfully
I wouldn’t call that code “successfully”. Clang uses shufps instructions to shuffle integer vectors. On many CPUs, especially older than a couple of years, there’s a non-trivial latency, a few cycles, to pass data between integer and floating-point execution units. The approach is correct with these shuffles, but not the implementation. For integers it should have used at least _mm_shuffle_epi32 = pshufd or _mm_slli_si128 = pslldq, but ideally _mm_alignr_epi8 = palignr from SSSE3.
Its an "auto-vectorization success", because the pseudo-code I've laid out auto-vectorizes to SSE 128-bit, AVX 256-bit, and AVX512 512-bit code. Furthermore, CLang supports many architectures, and probably can convert my simple for-loop into SIMD on other architectures without any intervention (ie: ARM Neon)
The code doesn't need to be optimal, it just needs to be faster than the scalar form and portable.
“yes” because what clang did is a very good job, pretty sure it substantially faster than scalar code. Especially on newish CPUs like Zen2.
“no“ because from what they have currently for that particular test case, they only need a few improvements to make their code as fast as a manually vectorized version. Apparently, the compiler is already aware of the cost of RAM loads. They just need to account for these bypass delays between integer and FP execution units in their code generator.
I mention this because I don't share your certainty that avoiding duplicate loads is necessarily "faster". I've occasionally been able beat compilers at getting higher throughput by making the duplicate load, despite the fact the latency increases. This is because vector shuffles (and I think aligns and inserts) are limited to a single execution port (at least on recent Intel processors), while loads can be executed twice per cycle. Depending on the mix of the rest of instructions, this means that sometimes the redundant load ends up being the better choice.
Which is to say, rather than being sure, measure!
But still, that particular clang output is suboptimal. They have already optimized loads with these shuffles. I think they did 80% of the work already. They are just using the wrong shuffles.
But sure, SIMD is no panacea. Especially when the latency between the vector instruction shares transistors with the previous instruction.
It generates vector instructions for all k8-like architectures, with wider instructions starting from -march=sandybridge.
vmovss, vmulss and vaddss instructions are not vector. They only load/store, multiply or add the lowest lane of their operands. The equivalent vector instructions are vmovaps, vmulps and vaddps respectively, these would load, multiply or add complete vectors, not just the lowest lanes of them.
-ffast-math allows the multiply-adds to be reassociated, enabling much better approaches. Clang with -O2 -ffast-math produces good code (vfmadd132ps with 4 independent accumulators), I can't get GCC to produce good code with any flags.
`-funroll-loops` by default unrolls 8 times. Unrolling beyond the number of accumulators is wasteful for simple operations like dot procuts or summations, so you may want to control that with `--param -max-unroll-times=4`.
Thus, the following works and will produce generally faster code than LLVM (because LLVM doesn't vectorize the remainder, giving you potentially large numbers of scalar operations):
`-Ofast -funroll-loops --param max-unroll-times=4 -fvariable-expansion-in-unroller --param max-variable-expansions-in-unroller=4`
Godbolt: https://godbolt.org/z/4PXSqs
$ gfortran-10 -c dot.f90 -Ofast -fopt-info
dot.f90:3:7: optimized: loop vectorized using 16 byte vectors
dot.f90:3:7: optimized: loop with 2 iterations completely unrolled (header execution count 64530389)
$ gfortran-10 -c dot.f90 -Ofast -fopt-info -funroll-loops --param max-unroll-times=4 -fvariable-expansion-in-unroller --param max-variable-expansions-in-unroller=4
dot.f90:3:7: optimized: loop vectorized using 16 byte vectors
dot.f90:3:7: optimized: loop with 2 iterations completely unrolled (header execution count 64530389)
dot.f90:4:0: optimized: loop unrolled 3 timesIt was personally eye opening to see how vectorization can be the gift they keeps on giving when you can sprinkle it throughout a computational pipeline vs spreading work across cores.
Here's the episode where SIMD is properly introduced: https://guide.handmadehero.org/code/day115
I also made offline docs, there: https://github.com/Const-me/IntelIntrinsics
https://software.intel.com/sites/landingpage/IntrinsicsGuide...
Intel's intrinsics guide has an approximate clock-count on all instructions. There are a few obscure details to memorize, but if you already understand super-scalar, pipelined, and OoO operations of modern CPUs, its all straight-forward.
---------
The last 10% of using SIMD successfully is obscure and eclectic. Its not "difficult", its just a whole bunch of tricks that you'll probably never figure out on your own. I'm talking about pshufb magic, prefix sum / scan patterns, gather/scatter patterns, and the like.
As complicated as pshufb is, the Intel intrinsic documentation does a great job precisely describing the operation: https://software.intel.com/sites/landingpage/IntrinsicsGuide...
_mm_shuffle_epi8 gives you a register-to-register (no RAM touched, faster than even L1 cache) 16-byte lookup table. An alternative viewpoint: it gives you a register-to-register arbitrary gather command. I've seen so many tricks from this one instruction alone.
---------
Another straightforward guide is Intel's optimization manual. Its straightforward, but a lot of material to read: https://www.intel.com/content/dam/www/public/us/en/documents...
Chapters 4, 5, and 6 deal with SIMD coding.
[1] http://www.cs.utexas.edu/users/flame/laff/pfhp/week2-optimiz...
It's 2020, who uses variable names like 'acc', 'a' and 'b'?
There's also no real explanation of what 'a' and 'b' (or 'p1' and 'p2') actually are. It's just magic.
I get that it's just an example, but the example feels so useless it doesn't demonstrate why these SIMD calls are helpful at all.
Mathematicians, scientists, engineers, artists. Have you read any numerical work? They often use one-letter variables, or otherwise very abbreviated variables. Sometimes they differentiate variables with the same letter using by using different fonts. Seems strange to us, but clearly it works for them!
And then they keep doing the same thing when they translate to code. When I ask them they say they think our long variable names are unreadable. Maybe they're right and we're wrong? Who knows.
They generally don't use tests, linting, CI, anything like that either. But again, clearly it works for them!
Even more, sometimes for clarity or performance reasons it may make sense to introduce interim variables, I often name those just as what they'd look like in formula, say, pi/2, would be PI_2; A*B is kept in AB, or AxB depending on context. So many times the encoded formulas end up hiding accidental errors resulting from a desire to use programmer's names. With scientific code, it's safer to stick to formulas, as it's just easier to eyeball them or outright copy and paste to/from CAS for cross-check.
Of course, it's paramount to have proper test-cases to make sure the encoded formulas match to the expected results.
As for the example: if you think a dot product is useless, then maybe SIMD really is not useful for you, and you can forget about it if you want.
acc stands for “accumulator”. p1 and p2 are two pointers, of type const float*. You’ll find more context on github, here’s the repository with these samples: https://github.com/Const-me/SimdIntroArticle