Optimizing compilers reload vector constants needlessly
lemire.me
lemire.me
> Optimizing compilers reload vector constants needlessly
...is absolutely true. I wrote some code that just does bit management (shifting, or, and, xor, popcount) on a byte-level. Compiler produced vectorized instructions that provided about a 30% speed-up. But when I looked at it... it was definitely not as good as it could be, and one of the big things was frequently reloading/broadcasting constants like 0x0F or 0xCC or similar. Another thing it would do is to sometimes drop down to normal (not-SIMD) instructions. This was with both `-O2` and `-O3`, and also with `-march=native`
I ended up learning how to use SIMD intrinsics and hand-wrote it all... and achieved about a 600% speedup. The code reached about 90% of the performance of the bus to RAM which was what I theorized "should" be the limiting factor: bitwise operations like this are extremely fast and the slowest point point was popcount which didn't have a native instruction on the hardware I was targeting (AVX2). This was with GCC 6.3 if I recall, about 5 years ago.
I got into the nastiest discussion on reddit where people were swearing up and down it was impossible to beat the compiler, and handwritten assembly was useless/pretentious/dangerous. I was downvoted massively. Sigh.
Anyways, that was a year ago. Thanks for another point of validation for that. It clearly didn’t hurt my feelings. :)
I never come across people in the wild that actually do this also, it’s such a niche area of expertise.
I do wonder whats going on with projects like BOLT though. I have seen it was merged into LLVM, and I have tried to use it but the improvement was never more than 7%. I feel like it has a lot of potential because it does try to take run-time into account.
If your use case isn't straining icache then you won't benefit as much.
BTW 7% is huge, odd that you would describe it as "only".
It depends on what you're doing and how optimized the baseline performance is. In my area (CRDTs) the baseline performance is terrible for a lot of these algorithms. Over about 18 months of work I've managed to improve on automerge's 2021 performance of ~5 minutes / 800MB of ram for one specific benchmark down to 4ms / 2MB. Thats 75000x faster. (Yjs in comparison takes ~1 second.)
Almost all of the performance improvement came from using more appropriate data structures and optimizing the fast-path. I couldn't find an off-the-shelf b-tree or skip list which did what I needed here. I ended up hand coding a b-tree for sequence data which run-length encodes items internally, and knows how to split and merge nodes when inserts happen. CRDTs also have a lot of fiddly computations when concurrent changes edit the same data, but users don't do that much in practice. Coding optimized fast paths for the 99% case got me another 10x performance improvement or so.
I'd take another 7% performance improvement on top of where I am, but this code is probably fast enough. I hear you that 7% is huge sometimes, and a smarter compiler is a better compiler. But 7% is a drop in the bucket for my work.
Which in the majority of cases can be achieved by profile guided optimization anyways.
Statistics are worthless alone, at the end all that counts is the arena of performance and what the code becomes and how it runs against the handcrafted version.
I’m all for transparency but I’m also not about to get fired for posting our kernel convolution routines, or least squares fit model.
> It should be part of these discussions to proof what you claim
Further - these aren’t subjective claims that need to be proven on a forum for legitimacy. It’s the literal state of vector based optimisations in the compiler world right now. It is a hard problem and for the time being humans are much better at it. This is quite a large area of academic research at the moment.
If someone is so uninformed of this domain that they don’t know this, the burden is on that person to learn what the industry is talking about. Not the people discussing the objective state of the industry.
In the meantime, I constantly encounter algorithms that compilers fail to vectorize because even single vector instructions are too complex for the compiler to match, such as saturating integer adds. The compiler fails to autovectorize and the difference in performance is >5x. Even just something simple like adding up unsigned bytes, and all three major compilers generate vector code that's much slower than a simple loop leveraging absolute difference instructions.
That's even before running into the more complex operations that would require the compiler to match half a dozen lines of code, like ARM's Signed Saturating Rounding Doubling Multiply Accumulate returning High Half: https://developer.arm.com/architectures/instruction-sets/int...
Or cases where the compiler is not _allowed_ to apply vector optimizations by itself, because changes to data structures are required.
It is that! As I stated in another comment, this niche ended up saving the company literally millions of dollars in hardware costs. To be fair, it really should only be done in highly performance-critical situations and only after an initial implementation is already written in pure C++ with thorough unit testing for _all_ input/output cases.
Now I work for a company to writing software to control drones. It doesn't pay as much as I want, but it's at least fun and there's a ton of unsolved automation problems at the company. And we're hiring like crazy -- from 6 people to 300+ in the past two years, and there's no sign of slowing down. And I'm now a manager too... that's the really scary part lol
It _should_ be useless (for some reasonable definition of "should") — it just isn't in practice. And I'm continually amazed at how often people confuse one for the other, across all contexts. E.g. I have family members who refuse to consider that our justice system might have deep flaws, because to their mind if it should be some other way then it already would be.
This isn't only because it's faster, it's honestly easier to read than intrinsics anyway. What it does lack is debugability.
https://github.com/xianyi/OpenBLAS/blob/02ea3db8e720b0ffb3e2...
https://github.com/xianyi/OpenBLAS/blob/02ea3db8e720b0ffb3e2...
Pipelining and optimisations can make the intrinsics a bit fucky though, have to make sure it’s -O0 and a proper debug compilation.
I have line by line debugged raw assembly many times. It’s just a pain to initially set up. Honestly not very different from c/c++ debugging once running.
When I wrote a transcoding multimedia server I ended up writing my own framework and simply pulling in the low-level decoders/encoders, most of which are maintained as separate libraries. I ended up being able to push at least an order of magnitude more streams through the server than if I had used ffmpeg (more specifically, libavcodec) itself, even though I still effectively ended up with an abstraction layer intermediating encoder and format types. And I never wrote a single line of assembly.
There's no secret sauce to optimization: it's not about using assembly, fancier data structures, etc; it's learning to identify impedance mismatches, and those exist up and down the stack. Sometimes a "dumber" data structure or algorithm can create opportunities (for the developer, for the compiler) for more harmonious data and code flow. And impedance mismatches sometimes exist beyond the code--e.g. mismatch between functionality and technical capabilities, where your best might be to redefine the problem, which can often be done without significantly changing how users experience the end product.
This is so confusing I can’t tell if you’re actually talking about libavcodec. The whole point is to combine codecs to share common code, “most” decoders certainly aren’t available elsewhere.
If you just want to call libx264 directly go ahead and do that of course. libx264 uses assembly just as much or more than libavcodec though.
This reminds me of a retrospective of an OS/window manager written in assembly - they were great about avoiding tiny overheads, but expressed regret that the whole system ended up slow because it was hard to reason about bigger things such as how often to redraw everything, similar to what people are saying here.
To be clear: let's indeed optimize and vectorize, but better to build on intrinsics than go all the way down to assembly.
There're too many different assemblies: inline, MASM, NASM, FASM, YASM. They come with their unique quirks, and they complicate build.
Intrinsics are more portable. It's trivial to re-compile legacy SSE intrinsics into AVX1. You won't automatically get 32-byte vectors this way, but you will get VEX encoding, broadcasts for _mm_set1_something, and more.
Readability depends on the code style. When you write intrinsics using "assembly with types" style, actual assembly is indeed more readable. OTOH, with C++ it's possible to make intrinsics way better than assembly: arithmetic operators instead of vaddpd/vsubpd/vmulpd/vdivpd, strongly-typed classes wrapping low-level vectors for specific use cases, etc.
Update: most real-life functions contain scalar code (like loops), also auto-generated code (stack frame setup, back up / restore of non-volatile registers). When coding non-inline assembly, developer needs to do that manually in assembly, this can be hard to do, and may cause bugs like these https://github.com/openssl/openssl/issues/12328 https://news.ycombinator.com/item?id=33705209
> I never come across people in the wild that actually do this also, it’s such a niche area of expertise.
I'd guesstimate there are several hundred of us working on vectorization :)
Ludicrous! How could they be taken seriously? Which subreddit was this?
4400 MB/s vs 800 MB/s = 5.5x speedup or 5.5 times as fast.
Something that is 50% faster will take half the time. Something that is 100% faster will take a quarter, and so on.
Until very recently GCC didn't do vectorization at -O2 usless you told it to.
1. It thinks it is cheaper than keeping them in a register ( this is known as rematerialization). It will reload constants that it lets it keep something else in a register, and it's cheaper to do this.
2. It thinks something could affect the constant.
3. It thinks it must move it through memory to use it, and then it thinks the memory was clobbered.
In this case, it definitely knows it is a constant, and it can't prove that both loops always execute, so it places it in the path where it is only executed once per loop, because it believes it will be cheaper.
I can still make at least gcc do weird things if i prove to it the loop executes once.
In that case, what is happening in gcc is that constant propagation is propagating the vector constant forward into both loops. Something later (that has a machine cost model) is expected to commonize it if it is cheaper, but never does.
That is normal and what i would expect to happen.
You can see in lower level RTL dumps, nothing chooses to commonize it. That seems a bit weird, since it should have a cost model that says this isn't free.
A lot of the intrinsics also look like (at least in gcc), they are marked always inline but not pure/const.
it's probably worthwhile marking those that take memory as pure/const. That way, it's obvious the value is readonly even when it's inlined.
(otherwise, the compiler will have to prove it in the inlined code, which is not always possible)
__m256i mask = _mm256_set1_epi8(0x0f)
If you just used the intrinsic that sets the register to a constant over and over, it often repeats the instruction.
The compilers just aren't that smart about SIMD yet.
__m256i c = _mm256_set1_epi32(10001);
And then the disassembly has mov eax, 10001
vpbroadcastd ymm1, eax
before each loop.Optimal register allocation + spilling is easily doable now on today's machines/algorithms.
We just don't bother because it's not truly worth it in most cases (IE we get most of the performance for a very low cost)
There’s some tutorials but honestly the best thing is to just use them.
Write an image processing routine that does something like apply a gaussian blur to a black and white image. The c++ code for this is everywhere. You have a fixed kernel (2d matrix) and you have to do repeat multiplication and addition to each pixel for each element in the kernel.
Write it in C++ or Rust. Then read the Arm SIMD manual, find the instructions that do the math you want, and switch it over to intrinsics. You are doing the same exact operations with the intrinsics as the raw c++. Just 8 or 16 of them at a single time.
Run them side by side for parity and to check speed, tweak the simd, etc.
Arm has good (well ,okay) documentation
https://developer.arm.com/documentation/den0018/a/?lang=en
https://arm-software.github.io/acle/neon_intrinsics/advsimd....
* Edit: you also have to do this on a supported architecture. Raspberry pi’s have a neon core at least in the 3’s. Not sure about the 4’s but I believe so too!
Go to https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
Start with SSE, SSE2, SSE3
Write small functions in https://godbolt.org/ . Watch the assembly and the program output.
Intel's Intrinsics Guide is exactly what I used and it was before I learned about Compiler Explorer.
I already had a large and thorough suite of unit tests for inputs and expected outputs, including happy and sad paths. So it was pretty easy to poke around and learn what works, what doesn't.
It was definitely time intensive (took about three months for about 50 lines of code) but it also saved the company a few million dollars in hardware (DNA analysis software to compare a couple TiB of data requires a _lot_ of performance). I have since moved to a different company, partly because I never saw a bonus for saving all that money.
The intrinsics guide does good to show what's available but it does not do a good job of documenting how each instruction actually works... many intrinsics are missing pseudocode and some pseudocode can have ambiguous cases. I used GDB in assembly mode to compare that table against the register content instruction-by-instruction to figure out where I misunderstood something if something went awry.
Frustratingly, some operations are available in 64-bits but not bigger, some in 128-bits but not bigger, etc. So I wrote up a rough draft in LibreOffice Calc with 64, 128, and 256 columns to follow the bits around every intended operation. I then correlated against the intrinsics guide to determine what instructions are available to me in what bit sizes. For a given test run, each row in the spreadsheet was colored by what the original data contained, another row for what I needed the answer to be for that test case, then auto-color another row's cell green or red if the register after a candidate set of instructions did or didn't match the desired output. Any time I had to move columns around (the data was 4-bits wide), I'd color a set of 4 columns to follow where they go during swizzling.
Are these helpful, or not good enough to write performant vector code?
For cross-platform, your best bet is probably https://github.com/VcDevel/std-simd
There's https://eigen.tuxfamily.org/index.php?title=Main_Page But, it's tremendously complicated for anything other than large-scale linear algebra.
And, there's https://github.com/microsoft/DirectXMath But, it has obvious biases :P
We do indeed have cross-platform intrinsics here: github.com/google/highway. Disclosure: I am the main author.
Do you have any advice on how someone limited to c99/c11 can still leverage the wisdom and techniques inside it?
For those that are new to this, can you give an example of a kind of computation or algorithm which is well-served by your project, but not possible with vector extensions like https://clang.llvm.org/docs/LanguageExtensions.html#vectors-... ?
Agreed. Usually the interface would be something like RunEntireAlgorithm(), not DotProduct().
> For those that are new to this, can you give an example of a kind of computation or algorithm which is well-served by your project but not possible with vector extensions
Sure. Vector extensions are OKish for simple math but JPEG XL includes nontrivial cross-lane operations such as transpose and boundary handling for convolution. __builtin_shufflevector requires a known vector length, and can be pessimized (fusing two into one general all-to-all permute which is more expensive than two simple shuffles).
Also, vqsort (https://github.com/google/highway/tree/master/hwy/contrib/so...) almost entirely consists of operations not supported by the extensions, and actually works out of the box on variable-length RISC-V and SVE, which compiler extensions cannot.
I remember us looking deeply into this and decided to hand write the SSE intrinsics. They usually map 1:1 but we had some unexpected differences in algorithm output between the x86 binary and the ARM binary when compiled with this.
But this was also back in 2019 or so, maybe it’s better now!
https://godbolt.org/z/61jYejsra
With the original, for-loop version, it could be argued that in case no loop iteration gets run at all, the vpbroadcastd's can be totally skipped, and to generate extra code for different cases of whether each loop is empty to avoid unnecessary vpbroadcastd's is not worth it (e.g. the greater icache pressure resulting from longer code).
With the do-loop variant, both loops will get at least one iteration so the compiler really could just unconditionally do the vpbroadcastd once before the first loop. It somehow fails to realize that ymm1 did not get clobbered between the two vpbroadcastd's.
It is perfectly reasonable to take a look at the output on Godbolt, tweak it a bit and call it a day.
Maintaining a full assembly language version of the same code is rarely justifiable.
And yet, I understand the itch, especially because there are quite often some low-hanging fruits to grab.
https://stackoverflow.com/questions/1422149/what-is-vectoriz...
https://godbolt.org/z/Td6vG9cqG
edit: uh, the constant requires some hand adjustment
edit2: fixed version https://godbolt.org/z/4Px5Mbsx4, and I just don't get this. gcc really just wants to load that constant twice.
mov eax, 10001
vpbroadcastd ymm1, eaxCan anyone recommend a (r)introduction to modern vector programming on CPUs? I was last fluent in the SSE2 days, but an awful lot has happened since - and while I did go over the list of modern vector primitives (AVX2, not yet AVX512), what I'm missing is use cases - every such primitive has 4-5 common use cases that are the reason it was included, and I would really like to know what they are ....
In other words, I suspect an invariant calculation inside consecutive loops but that is not vectorized will be pulled out of the loops and also moved prior to them and executed just once.
Guess what? It's jquery.