Do low-level optimizations matter? Faster quicksort with cmov (2020)
cantrip.org
cantrip.org
CMOV has been added by Intel in 1995, to Pentium Pro. It was the most important addition to the x86 ISA added by Pentium Pro. So CMOV was supported by Pentium Pro and its successors, like Pentium 2 or Pentium III, but it was not supported by Pentium, Pentium with MMX or AMD K6 CPUs. AMD has added CMOV starting with Athlon, in 1999.
Pentium Pro was the first Intel CPU with out-of-order execution and it did much more speculative execution than its predecessors. Therefore it had the need for CMOV to limit the performance loss caused by branch mispredictions.
Before we conclude anything, we should remind ourselves of its limitations. The tests run were on completely random data. Truly random data seldom occurs in real life.
Linus famously ranted about CMOV in https://yarchive.net/comp/linux/cmov.html (2007, so potentially more modern architectures are better at some of this) and he says: if you KNOW the branch is totally unpredictable, cmov is often good for
performance. But a compiler almost never knows that.
As usual with optimizations like this you have to benchmark and even then if your sample isn't representative it might not mean much.https://github.com/Voultapher/sort-research-rs/blob/main/wri...
Notice how random_s95 is worse (not by much, but it's there) than fully random. random_s95 is 95% sorted data, but 5% unsorted, simulating a common "sort, do stuff, append, repeat" pattern we see in a lot of software.
In contrast the sorted cases are almost instant, and random_d20 (only 20 distinct values, chosen at random, but as a result the sorted output needs much fewer comparions) is very fast.
I've definitely measured scenarios where cmov/branchless is slower than a branch for a given algorithm. Especially if the branchless version is doing a bit more work to avoid the branch.
I haven't seen cmov lose to a conditional in a few years (on very recent Intel hardware). Maybe there might be some circumstances where in creates a loop carried dependency that wrecks the optimizer, but I haven't seen one in a minute.
To be fair - cmov versions often involve some extra compution to get things in a form where cmov can be used, and I often just opt for the well predicted conditional since those couple extra instructions to set up the value to move can be avoided.
Also, CMOV did indeed get significantly better.
Wasn't "cmov" one of the things added for the pentium pro? So it wasn't instruction compatible - hence the "i686" prefix to a lot of compiler triples?
Something similar is coming to Rust as well: https://doc.rust-lang.org/nightly/core/intrinsics/fn.select_...
But it doesn't! the sequence 1010101010... has probability 50% that it assumes one or the other value, yet is completely predictable.
Which is unfortunate because these are genuinely sometimes useful, but it's a "We can't have nice things" situation - for every expert who is carefully applying a hint to a hot path after careful reading of the manual for the specific CPU model they use, there are dozens of idiots who will copy-paste that hint believing that it's magic speed-up juice, never having measured and with no idea what it's really doing or why.
The intrinsics have the advantage that at least you're expressing what you meant, and it is obvious whose fault it is when that's slower because you screwed up - hints make it too easy for programmers to justify a tantrum when their "hint" isn't magic speed-up juice after all.
IMO the constant-timeness is much more important, e.g. in audio DSP code or for cryptography you really care that it's a `cmov`, regardless of the predictability of the predicate.
There's a real difference between the wishful thinking which leads to programmers writing incorrect hints and them writing code that outright says what it's doing and then being surprised it did what they asked.
The interesting part is how that was done:
> A New Primitive, swap_if
> How can we use this method in our sort? First, let us make a swap_if:
inline bool swap_if(bool c, int& a, int& b) {
int ta = a, mask = -c; // false -> 0, true -> 111..111
a = (b & mask) | (ta & ~mask);
b = (ta & mask) | (b & ~mask);
return c;
}
> In our partition function, then, we can transform if (*right <= pivot) {
int tmp = *left; *left = *right, *right = tmp;
++left;
}
> into just left += swap_if(*right <= pivot, *left, *right);What I mean is, say you have a similar simple "x = (cond) ? a : b;" which the compiler has not translated to a CMOV.
If this is in a hot loop then the CPU could, in theory, notice that "it's just doing a conditional move, I can do the CMOV faster" and then translate those code bytes at that memory location to a CMOV instead (ie during decoding or something like that).
Not worth the complexity? I imagine it would slow down the decoder or wherever they insert this replacement logic. Or am I hopelessly out of date and they're already doing this?
Wasn't that one of Transmeta's things?
As a relevant example, POWER CPUs can convert some non diverging branches into conditional execution if not well predictable.
Many RISC CPUs can convert well known LL/SC code patterns into locked atomic operations to guarantee forward progress.
a ^= b
b ^= a
a ^= b
(Here ^ denotes bitwise XOR)Allowing for the mask, one could do an in-place version of swap_if via
a ^= (b & mask)
b ^= (a & mask)
a ^= (b & mask)
The in-place version of swap is generally discouraged because compilers are smart (https://stackoverflow.com/questions/36906/what-is-the-fastes...) but I do wonder whether the masking in swap_if obscures intent to the compiler enough to close the gap.Assuming the mask is passed in, Godbolt puts OP’s swap_if at 26 instructions, versus the above swap_if at 17 instructions: https://godbolt.org/z/Wedco5hPv
Isn’t that more because CPUs slow down when there are dependencies between instructions?
Compilers could (and may even do) fairly easily detect that pattern (like they do with some versions of popcnt. See for example https://langdev.stackexchange.com/questions/3942/what-are-th..., discussed here in https://news.ycombinator.com/item?id=40987123) and compile it to whatever is fastest on the target CPU/in the given context (in some contexts, it can be compiled to nothing, just changing the mapping between local variables and the registers they’re stored in)
Indeed, while reg-reg moves can be resolved at the rename stage and are effectively free latency-wise.
You wouldn't want to have to check each lane individually since that would spoil the point of using SSE. So you'd write masking ops like this and keep things parallel, tThen judiciously use any() and all() type intrinsics to check whether the lanes could agree to skip certain blocks conditionally.
(This was before ISPC and before autovectorization to handle it for you.)
For example, here's a simple Compiler Explorer link with a simple loop conditionally copying from one of two values:
https://godbolt.org/z/TbeK99sx1.
If you look at the assembly code produced, you'll see in lines 9-11, it's doing a similar thing as the swap_if() function: ANDing one value with the mask, ANDing the other with the negation of the mask, and then ORing them together. And it produced that automatically from the ternary in the loop.
They can also do things like automatically generate epilogues with a serial loop to handle the leftover items if the loop count isn't an even multiple of the SIMD width. That's something else that I remember writing by hand before autovectorizers.
That said, coaxing an autovectorizer to actually vectorize a loop can be an art in itself. They're notoriously finicky. My favorite used to be the one in the old ("classic") ICC compiler where you use compiler flags to get a nice optimization report which would explain exactly why it chose what it did for each loop. You could also set #pragma's which would noisily warn or error out if the associated loop failed to vectorize for some reason.
(And of course, a lot of this stuff you might just use GPU compute for now.)
There shouldn't be a need to do any bitwise magic.
It's up to you to choose your target adequately based on your deployment requirements.
The Lomuto partitioning is a naive vandalism of Hoare's original, which causes quicksort to have quadratic behavior on sorted input.
SIMDjson is another example that comes to mind. The conceit of C is that you do t have control over the underlying machine instructions without inlining it yourself. So how do people write C code with cpu optimizations like this?
Resources sufficient for implementing simdjson or similar vectorized parsers: https://www.intel.com/content/www/us/en/docs/intrinsics-guid... https://lemire.me/blog/ http://0x80.pl/articles/
1) Use intrinsics, if your platform provides intrinsics for the instructions you want to use.
2) Use inline assembly (which I suppose doesn't technically count as writing C code, but is very much part of the story).
3) Write C code carefully against a chosen compiler with chosen optimization flags, and inspect the assembly. Especially for small chunks of code, a tool like godbolt.org is indispensable. Basically, you're writing the assembly you want the compiler to generate (either explicitly, or just in your head), then writing C code that seems likely to generate that sequence of instructions, then tweaking the code until the output you want is generated. If this is a super important optimization, it's also reasonable to add a build step that inspects the generated assembly and fails the build if it doesn't match the desired pattern; but in that case, writing inline assembly is usually easier.
As a way to show the wonderful complexities of compilers check the TOC of https://shop.elsevier.com/books/optimizing-compilers-for-mod...
Rethinking Binary Search: Improving on a Classic with AI Assistance: https://www.youtube.com/watch?v=FAGf5Xr8HZU
The gist is rather simple: Assuming that a substiantial amount of your searches results in no result, you can bias the binary search to jump farther then just half, improving runtime noticeably.
If your primary audience is other developers then it absolutely does not matter. In fact it’s a way to expose deep anger. All that matters is convenience and measurements just piss people off, because measuring anything is just too hard.
If your primary audience is business, especially transactional finance, then absolutely yes. Squeeze absolutely every millisecond out, measure absolutely everything in terms of transaction quantity, focus on concurrent operations.
> What can we conclude from this discovery? Before we conclude anything, we should remind ourselves of its limitations. The tests run were on completely random data. Truly random data seldom occurs in real life.
it's true that it's not a common case, but it's probably the simplest objectively justifiable case; any particular case of the others requires more elaborate justification for privileging it. why 90% sorted instead of 80%, say? or why do we give 20% weighting to the randomized-data case and 80% weighting to the 90%-sorted case, rather than some other weighting?
and it does at least guarantee exploration of a good part of the algorithm's behavior space. i mean, bogosort is optimal on fully-sorted data, right? as long as you do the check for sortedness before the random shuffle instead of after it
it's important that your sort not explode on mostly-sorted or mostly-reverse-sorted data (myers's 'bog-standard quicksort' uses the last element as the pivot and consequently goes quadratic), but beyond that, the particular weighting of relative importance to assign to random input vs. 99% sorted vs. 90% sorted vs. 90% reverse sorted—that requires some application-specific justification
aside from the ones you mentioned, another interesting case is a large array of records that all have equal keys. median-of-three quicksort or random-pivot quicksort does fine on mostly- or fully-sorted data, but still sucks when everything is equal!
So the baseline radix sort can be even faster.
The radix sort was used to provide an estimate for how much better the quicksort could improve.
The C++ standard promised (since 1998) that the provided sorts are O(n log n).
But in fact popular C++ implementations ignored this and shipped quicksort - after all it's "pretty fast" but unfortunately this means if bad guys can choose your input (or you get unlucky) you get O(n squared)
Gradually they fixed this, libc++ updated to Introsort (last century's best attempt to do this) in 2021. In response to a ticket opened by Orson Peters.
These days you're also starting to see a trend towards the Rust-style "fast sort which implements a wide contract" design even in C++ implementations. Rust's sort doesn't actually promise this wide contract arrangement, but it does promise safety and the wide contract turns out to be the simplest way to do that.
What I mean there by "wide contract" is that these sorts don't blow up if your ordering rule is nonsense. They can't "sort" successfully because your ordering rule is nonsense, if A > B && B > A then too bad, we can't sort A and B. But they can and do gracefully give up, while many older C++ stdlibs just cause uncontrolled mayhem in this case.
In fact the standard didn't require O(log n) untill C++11 as all implementations were already using introsort, so allowing the previous more permissive O(n^2) bound was no longer necessary.
As I said, Orson filed a bug in LLVM bugzilla for libc++ going quadratic for adversarial input in 2014, and it was eventually fixed (by using introsort) in 2021.
There's a fun sideshow in that ticket, remember Orson actually wrote what is probably the best general purpose unstable sort at the time - but all he's done is open a ticket saying libc++ doesn't meet the minimum requirements. After a while, with the team having dragged their feet and still not even landed the patch to just use introsort somebody pipes up to report that they have benchmarked sorts and actually libc++ is faster than other popular C++ sorts (in their benchmark). So, Orson politely asks them to try PDQsort. That's the first time he explicitly mentions his Pattern Defecting Quicksort in the ticket, and maybe a harried LLVM bug handler wouldn't notice who filed the bug until then. Just another name.
But it's OK, the LLVM devs are unperturbed and ignore him, focusing instead on the minutiae of the performance numbers for their known defective unstable sort. Year later somebody eventually adds introsort to libc++ and closes the defect ticket. Nobody answers Orson's question, the answer of course is "Oh, yours is much faster".
libc++ is notable for being one of the few from-scratch implementations.