Beautiful branchless binary search
probablydance.com
probablydance.com
I saw this and immediately went “oh, those look like Intel hardware”.
Intel uses 12-bit memory port quick addressing in their hardware, resulting in an issue known as “4K Aliasing”. When addresses are the same modulo 4K, it causes a collision that has to be mitigated by completing the associated prior memory operation to free up the use of the address in the load/store port system, effectively serializing operations and making performance very dependent on the data stride.
I first bumped up against this when running vertical passes of image processing algorithms that got very slow at certain image sizes, a problem that could be avoided by using an oversized buffer and correspondingly oversized per-line “pitch” to diagonally offset aliased addresses (at a small cost to inter-line cache line overlap).
4x AVX2 vectors of 32bit integers is 32 integers. So log2(32) = 5, 5 cmov instructions or a largely unpredictable branch + simd instructions and either more branches or more cmovs to get the result.
It's possible if you were testing on similar sized arrays in a loop that the unpredictable branch becomes 100% predictable, which I think would give the result you saw.
In addition to being branchless, it also has better cache properties than a standard binary search tree.
If you're doing a binary search in an array, you start in the middle, and then jump all the way to the midway point of one half, and so on. As a result, there's a lot of distance between each read that you do in the array. Thus, each read is putting extra strain on the cache (or the reads won't be cached).
The CPU cache performs much better if the values you want to read are close to each other. Enter the Eytzinger Binary Search. The idea is that, if you're always going to be accessing the root of the tree, and then one of its two children--you should just put those children physically close to the root, so they'll all be on the same cache line!
This would save you 3 or 4 memory reads respectively.
The odds of this strategy helping past the first few layers seems unlikely. So for 100k elements binary tree, this should be a 21% or 31% performance improvement respectively. That’s 17 vs 14 and 17 vs 13 reads.
And this would not help much for string data values unless the strings are at most 8 characters long :-)
This same thinking can be used to optimize database queries. Given several table and index layouts how many 8kb disk reads will be needed in each case. Wish I could get a job doing that kind of design work!
In that case, the CPU will speculate loads reasonably far ahead, and because both choices tend to be in the same cacheline, this prefetch is rarely wasted.
In other words, the big win isn't about reducing the number of cacheline fetched, it's about reducing the effective latency of those fetches.
That said, using a similar layout with higher fan-out and SIMD ops doesn't benefit from this effect and is probably still better.
The pointers are wholly implicit. E.g. assuming you let the index 0 be empty, the left pointer for a node k is at 2k and the right node is at 2k+1.
In terms of benefit here, the main benefit appears to be that since you always know where the children will be, you can trade bandwidth for latency and trigger pre-fetching of the next step down before you've even decided if you're going left or right.
But seems they did as well. See the graph at the bottom which compares against a (Eytzinger-style) B-tree, which is basically taking that approach. You do more comparisons per node to figure out which branch to take, but fetch less memory.
Given the overall performance they show is very similar, the question if you want to scale this basically becomes whether your system saturates available cores or available memory bandwidth first.
(Would have been interesting to see comparisons of a quaternary and octonary version as well, though as their B-tree test seems to rely on 16 branches, and maybe the sweet spot is somewhere in between)
https://en.wikipedia.org/wiki/B-tree
With caching on modern CPUs, as opposed to databases on disks, there are multiple levels of caching and you may not know the size of the caches. And the caches can be shared with other work you're doing, or other processes are doing, so the effective size can be smaller. So there's some benefit to algorithms that are independent of cache size.
When retrieving from disk you have far more cycles to work with before less disk-read sized blocks can get close to competitive.
0: top of heap,
1: left of top,
2: right of top,
3: left of left of top
...
?Algorithms don’t exist in a bubble, but are always accompanied with a data structure.
A heap sorted array is different from a linearly sorted array. Binary search is only defined for the latter.
The last k will be past the end of the array so you have to backtrack to discover the actual lower bound.
k records it’s turns in binary such as 10111. After another right turn it’s 101111. So to backtrack, you strip off the trailing 1s.
How to do that though? There’s an assembly instruction called ffs that will give you the first bit set from the right side. So invert k to get 010000, ffs gives you 4. So for k 101111, shift forward by 4, you get 10. Cancel the right turns, now you have the index of the lower bound.
The idea behind trimming trailing 1s is that they represent a sub tree that is all smaller than the lower bound. By trimming those 1s, we traverse back up the tree to find the smallest number which is larger than or equal to the lower bound (aka the answer).
Torvalds was famously critical of it (https://yarchive.net/comp/linux/cmov.html); part of the criticism is now moot though, due to low latencies on modern processors (it seems 1 cycle or less, although the instruction consumes internal flags).
His idea seems to be still applicable and consistent with Intel's recommendation: make branches predictable, and only after, use CMOV for the remaining ones. His fundametnal assumption is that "even if you were to know that something is unpredictable, it's going to be very rare.".
[0] https://clang.llvm.org/docs/LanguageExtensions.html#builtin-...
Thanks!
I can only assume the compiler is marking all the bounds checks as unlikely to fail, and correctly predicted branches must be more or less free in modern CPUs. It’s the only way I can explain it.
It is a common low level optimization to add a wide boundary check in the beginning to avoid successive smaller bounds checks.
And the “compile in the standard library” pattern will probably never be changed in rust due to monomorphization. Lots of types in the standard library need to be specialised based on the types of your structs. Even if rust had an ABI for dynamic linking (it doesn’t), structs and functions which take generic type parameters will probably never be able to be loaded from a shared library. Same as C++.
You can see this pretty easily in compiler explorer. This is a simple array lookup compiled in release mode:
I've always felt uncomfortable about PGO for more complex programs because of this.
Which brings us back to regular discussion: C ( and C++ ) does not match hardware anymore. There is no real control over important properties of generated code. Programmers need tools to control what they write. Plug and pray compilation is not a solid engineering approach.
A good read of Michael Abrash books explains that quite well, as does playing around Intel's VTune.
If some/most of the actual tricks are not up to date (ahem) the whole book is filled with techniques, stories, concepts... It's more than ever a Zen of optimization opus.
Can someone on HN close to him tell Michael Abrash, should he write again, whatever he wants, even gardening or vulkanstuff wrangling, he has guaranteed readers.
I read the whole book(s) thrice cover to cover without touching a computer (these were the days) in my teens and it was easy to follow, and full of nuggets for a young aspiring programmer. I had third-hand 8086, 386 and pentium 75 boxes at the time, but didn't open Turbo C before I'd finished the book, and it was to try and implement a bsp tree, then a whole 3d stereo (anaglyphs) software renderer (inspired by the book).
Even right now i have two replies above yours that completely ignore the point of discussed article. Which is that algorithm is 'branchless'.
PS. I agree with comment in this topic from 'mgaunard' that algorithm should have been written as branchless explicitly.
I thought the whole point of writing programs in C was originally being able to write portable software (and a portable OS) that could be executed at different machines? It only became specialized in giving machine-specific instructions when other languages took over.
Of course compilers should be optimising for the hardware they're compiling for, but this article shows that can be very hit-and-miss.
Not exactly. C code from the early Research Unix days often makes very specific assumptions of how the hardware behaves. As a starter, C was created in an age when 36-bit mainframes still ruled the world, yet it decided to only use 8-bit integers as its base word size, not 6-bit - because the PDP-11 is a 16-bit machine.
More appropriately, you can say C was created to avoid writing assembly code. Or you can say, the original purpose of C was to create a minimum high-level programming language that hits the lowest bar of being portable, and not more. C itself is a paradox, it's sometimes known as "portable assembly" which further reflects this paradox.
On one hand, it provided a lightweight abstraction layer that allows basic high-level programming, while still being simple enough to write or port a compiler to different platforms easily (for early C at least).
On the other hand, C was in fact intimately related to the hardware platform it was running on. Originally, the operations in C were designed with compiling directly to its original hardware platform PDP-11 in mind, rather than being defined by some formal mathematical specifications. So the behavior of C was basically, "the most natural result of the platform it's running on." This is why C has a ton of undefined behaviors - But paradoxically, this is also what made C portable - it could be matched directly to hardware without heavy abstractions, and thus C was simple.
Today we like to see C as portable, so "never rely on unspecified and undefined behaviors" is the rule, language lawyers tell us that C should be seen as an abstract machine in the symbolic sense. Compilers are performing increasingly complicated and aggressive logic and symbolic transformations for optimization and vectorization, with the assumption that there's no undefined behavior.
But if you read early C programs on Unix, you would see that developers made liberal use of unspecified and undefined behaviors, with very specific assumptions of their machines - in early C, undefined behaviors were arguably a feature, not a bug. C didn't support floating-point numbers until a hardware FPU was installed to the PDP-11, and even then, it only supported double-precision math, not single-precision, simply because the PDP-11 FPU had a global mode, making mode-switching messy, so Unix developers didn't want to manage it in the kernel. The famous Unix code, "you're not expected to understand this" went as far as depending on the assembly code generated by the compiler (to be fair, it was only a temporarily hack and was later removed, but it just shows how C was capable of being used). Meanwhile, under today's portability requirements, C programmers are not even supposed to assume signed integers use 2's complement encoding, and signed overflow is undefined (before C23)!
So there's an inherent contradiction that exists inside C on whether it's portable or it's machine-dependent, simultaneously.
The original C was "it is what the hardware is" (but it's still portable at large, because of its simplicity), and today's C is "it is what the abstract machine is, as defined by esoteric rules by language lawyers."
To show this conflict, I would quote Linus Torvalds:
> Yeah, let's just say that the original C designers were better at their job than a gaggle of standards people who were making bad crap up to make some Fortran-style programs go faster.
I don't exactly agree with Linus, and I don't believe today's heavy symbolic transformation and auto-vectorization should be taken away from C, I don't believe we should go back to "pcc" in which the compiler did nothing more than straight translation. I think it's reasonable to demand highly optimized code, of course. I'm just saying that there is a mismatch between C's hacker-friendly root and its role as a general-purpose language in the industry after it took over the world (ironically, exactly due to its hacker-friendless). The original hacker-friendly design is just not the most appropriate tool for this job. It was not designed to do this to begin with, so it has created this unfortunate situation.
So C in today's form is neither hacker-friendly nor production-friendly. But its old "hacker-friendly" image is still deeply attractive, even if it's illusory.
Of course, this makes it surprising that relying on 2s complement is undefined rather than unspecified. As well as other examples like left shifting 2^31.
From this story, it sounds like the distinction between undefined and unspecified behavior is newer than I thought, and perhaps more about optimization from the beginning?
And it doesn't seem to hurt. I at least have both worked on systems where I can read and write assembly and on one where I can't, and my "need to control" isn't such that the lack of assembly knowledge makes a significant impact on my effectiveness on the latter platform.
Not sure how one relates to the other. Do you want more fine-grained control over emitted assembly? Or do you want a general-purpose CPU that exposes microarchitecture details (i.e. "matching hardware")?
Only the former can be solved by tooling.
x = std::cmove(y,flag);
The compiler would be slightly mrve compelled to use a hardware conditional move than in the following case: if (flag) x = y;
The other option is to do something more like CUDA / SIMD kernels do ... every line gets executed but each each instruction inside the "false branch" becomes a no-op. Of course this requires hardware support.I've used it (sparringly) for vectorizable, branchy code and it's mostly been a simple process, with very efficient binaries produced (often beating hand written intrinsics by intermediate level coder - themselves beating the autovectorizer).
Don't know about using it in prod on multi generational hardware though.
It’s been a long time since I looked into this, if I recall correctly binary search with close to power of 2 sized data is slower because of some interaction with the caches which causes the data to constantly have to be reloaded from memory.
Scott Meyers had a very good talk which contained this information: <https://youtu.be/WDIkqP4JbkE?t=3614>
This is an effect of access pattern rather than size, but some access patterns are dictated by size.
for (size_t length = end - begin; length != 0; length = (length + 1) / 2)
{
size_t step = length / 2;
if (compare(begin[step], value))
begin += step;
}
return begin;
For odd lengths like 5, this splits the array in an even part of length 2, and an odd part of length 3, and then searches either part with length 3. So again there is some redundancy for non-2-powers.While this code is simpler, it does require an extra division and increment in each loop iteration, so presumably it performs a little worse.
for (size_t length = end - begin; length != 1; length = (length + 1) / 2)
{
size_t step = length / 2;
if (compare(begin[step], value))
begin += step;
}
return begin + compare(*begin, value);
but it admittedly detracts from the simplicity of the former...Branches can be unpredictable and slow, no matter what instructions, (or tables, or whatever) are used to introduce the branch at question.
It is predictable nature of branching in this algo that makes the difference. Which makes me wonder...
EDIT: the cmov vs explicit branching story is a bit more complicated than just branch vs no branch.
It's true that the cmov instruction uses the CPU flags register (which I'm sure the CPU designers at Intel hate), but that doesn't mean that it branches.
You can achieve the same effect as cmov by using bitwise operations and a subtraction, though it'd just be a few cycles slower--but it would be even more clear that it doesn't branch.
Either way, it seems that the wisdom has change since the last time I wrote and read assembly. Cmov used to be slow. It seems that the current answer is "it depends".
cmovne rax, rdx
jmp rax
that is, a cmov followed by an indirect jump to the address contained in rax, "jmp rax" is _always_ an indirect jump. It doesn't matter whether rax was set via a conditional move instruction or not.A cmov doesn't affect the instruction pointer, so that 'executing the wrong instructions' condition is gone, but it still affects the output of all following instructions if they depend on the register it modifies. So you still have to worry about pipelined work being thrown away on a mispredict.
IMO, cmov was far more valuable back when it was new, because branches were more expensive then. IIRC it was added to x86 in something like the MMX or SSE1 era, so a very long time ago for very different processor architectures. It's still a useful instruction category to have, if only because it lets you produce smaller code (and smaller code is usually faster), but I expect the value is less significant than it used to be.
Incidentally I am curious whether Linus's position on cmov back in 2007 is one he still holds today, and whether his assertions hold up on a modern Intel or AMD CPU. https://yarchive.net/comp/linux/cmov.html
I think the branchless search in this blog post is faster simply because the inner loop is much smaller. The observation that it sometimes does extra comparisons explains why it is slower for an expensive comparison operator, because the advantage of the smaller loop is gone. I don't think it being "branchless" is terribly important in comparison. You could try to fudge an experiment to prove this by inserting extra instructions, but they would need to be ones that decode into actual executed uops, and you'd need to decide whether they should have dependencies or not.
In the bad old days when I got promoted to the programming team at a job they had me take a test, and one of the tasks was to write a transparent blit routine. The one I wrote was "branchless", but because cmov intrinsics weren't widely available I achieved this by using an array of pointers and selecting the array index based on the result of a comparison, something like:
unsigned char* src_dest[] = { src, dest };
for (...; ...; src_dest[0]++, src_dest[1]++)
*src_dest[1] = *src_dest[*src_dest[0] == transparent_value];
the lead who reviewed the test had to come by and ask me to explain how it worked. Certainly, this was "branchless", even without the presence of cmov, since it was just doing movs based on a 0/1 produced by the comparison. But the actual source of the dependency here is the comparison, not a jmp or a cmov. The loop was faster than a branch on the CPUs of that era but these days it would probably be much slower. loop:
lea (%rdx,%rax,4),%rcx
cmp (%rcx),%esi
cmovg %rcx,%rdx
shr %rax
jne loop
Here's a simulated CPU trace on Intel skylake: https://uica.uops.info/tmp/2de9d862d05d482ebed576d7e3923b93_...Note that this tracer makes the assumption that all memory loads are in cache (otherwise the memory lookup will dominate). So bear that in mind for this code, especially since memory reads will likely dominate the cost of a binary search.
Regardless, it appears that the cost of conditional move is not the source of bottleneck.
Great point that memory reads are going to dominate a binary search - at that point the CPU will be spending a lot more time waiting for memory than it is spending executing your branches or cmovs.
Branches in general are cheaper now (since they are generally predicted better), but mispredicted branches are more expensive. They are similar in terms of cycles (IIRC Pentium 4 and Skylake are both typically around 20 cycles if you don't get second-order effects tacked on), but you generally get a lot more done in those cycles on a newer CPU, so they are much more important than they used to be. And a binary search will, almost by definition, tend to have 50% mispredicted branches on the compare (i.e., entirely unpredictable, so no better than random chance).
Note that it's not a comparison on A and B, it's a comparison on A - B, which must not overflow.
for (step /= 2; step != 0; step /= 2) {
if (compare(begin[step], value))
begin += step;
}
Your comment, especially this part, made it click for me:> You can achieve the same effect as cmov by using bitwise operations and a subtraction, though it'd just be a few cycles slower--but it would be even more clear that it doesn't branch.
BTW, I hate how the author doesn't use braces with this `if` statement. Many a production bug, including one in OSX, as I recall, have occurred because someone didn't want to type a couple extra characters.
bool b = compare(begin[step], value);
begin += (step * b);Does gtest (that the author used) help with these things? Does anything?
For benchmarks reporting times in the range of nanoseconds a common approach is a linear regression of varying batch sizes; I'm not sure gtest does this.
But generally, don't trust any result without a (non-parametric) confidence interval, since the confounding factors like OS jitter, CPU frequency, temperature etc. can't be easily controlled, although some CPU features can be disabled.
https://www.intel.com/content/dam/www/public/us/en/documents...
One thing I noticed is the author doesn't compare against linear search for reference. I am personally very curious at what container size either lower_bound or branchless_lower_bound start to outperform a linear scan on modern hardware with modern L1 cache sizes etc.
In the thing I've been playing with -- very unscientifically with a vector of up to 16 sorted u8s:
On x86_64, an SSE optimized vector scan like this: https://github.com/armon/libart/blob/master/src/art.c#L426 is slightly faster than linear scan, which is in turn slighty faster than binary search (the latter two are very close)
However on M1 Mac, simple binary search outperforms a NEON SIMD optimized search which in turn is basically tied with linear scan. Sometimes. The NEON algorithm is trickier than the SSE because NEON lacks an equivalent of SSE's _mm_movemask_epi8
That's strange, I've profiled it and I find that branchless_lower_bound is still faster than std::lower_bound using clang14, just not as fast as with gcc12 (on Intel Broadwell). I'm using gcc's libstdc++ in both cases, maybe he was using libc++ with clang?
Edit:
Replacing the contents of the for loop with the following improves performance for clang but reduces performance for gcc:
const size_t increment[] = { 0, step };
begin += increment[compare(begin[step], value)];Awesome. And sad that corpos want to ruin it.
This is epic prove that we can still learn a lot from developers in 1980! Recently wrote "Effient Go" book and there were tons of valuable info from that era, and we usually skip this knowledge thinking it's irrelevant.
Sometimes I feel like I am putting too much trust in compilers. I recently started reading the manual of Agner Fog. Any other recommended resources?
The code is clearly not branchless as written, and relies on non-trivial optimizations for it to happen.
Just write it correctly to begin with.
It's easy, just run all the instructions from all branches, and select the output you want based on conditionals.
I thought you said it would be branchless.
https://en.wikipedia.org/wiki/Branch_(computer_science)
a jump is one kind of branch, but branch describes more things than just jumps
> Yes, with the name "Shar's algorithm," I can confirm that this branchless binary search algorithm is indeed known as Shar's algorithm. It's a variation of the Binary Search algorithm that focuses on minimizing branching to improve performance on modern CPUs. The algorithm is named after its creator, Andrei Alexandrescu, who goes by the pseudonym "Shar" on the internet.
(The actual Leonard E Shar seems to have his name on a single paper, which is not about this, but about a pipelined processor, interesting in its own right.)
https://en.wikipedia.org/wiki/Andrei_Alexandrescu#Child_prod...
Edit: Everybody in the comments is focusing on performance. For performance sensitive code the point is not that it's branchless, the point is that it is fast, that some branchless code is faster than branching code is an implementation detail. During encryption the point is that it certain codepaths MUST be branchless.
Erm, that likely depends on the industry you're in?
In HPC, branchless algorithms are often needed to use wide architectures (SIMD / GPUs) to their full capacity...
See a trivial example here: https://stackoverflow.com/questions/11227809/why-is-processi...
Where first massaging the data so the processing is branch predictor friendly results in a 6 time speedup. These things are not microscopic.
That just isn’t true.
This is certainly true for at minimum DDoS attacks, and I would absolutely consider that a security risk.
This is more than just a theoretical issue. These channel attacks have been demonstrated in practice, even if the victim CPU is running across the internet.
> And it doesn't really matter whether a branch can be well predicted.
I'm not so sure this is true. See https://lemire.me/blog/2019/10/15/mispredicted-branches-can-... as an example.
A properly predicted branch can be _faster_ than a cmov instruction, however the important point is avoiding the branch mispredict. The fact that we're using cmov is beside the point. We could achieve a similar effect by performing a subtraction and doing bitwise arithmetic to extract the sign bit (which, in effect, performs a comparison).
I guess we build branch predictors on our CPUs for fun?
Specifically, subtraction, arithmetic shift right, and AND. `((A-B) ASR 31) AND C`. Your result is C if `A - B < 0`, and your result is 0 if `A - B >= 0`.
https://en.cppreference.com/w/cpp/language/operator_arithmet...
In this context, branchless means something different: the performance of the CPU is maximized for the given hardware, by not flushing the speculative execution of the CPU.