How We Beat C++ STL Binary Search
realm.io
realm.io
gcc 5.2.0 on Windows x64 (i5-3210M)
stl : 881 miliseconds
version 1: 880 miliseconds
version 2: 1607 miliseconds
version 3: 1260 miliseconds
version 4: 1271 miliseconds
gcc 5.2.0 on Linux x86_64 (i7-4770S) stl : 629.231 miliseconds
version 1: 629.436 miliseconds
version 2: 897.143 miliseconds
version 3: 862.827 miliseconds
version 4: 863.22 miliseconds
clang 3.6.2 on Linux armv7h (CuBox-i, Cortex-A9) stl : 3380.29 miliseconds
version 1: 3428.9 miliseconds
version 2: 3433.65 miliseconds
version 3: 3391.86 miliseconds
version 4: 3376.91 miliseconds
Oh, and Visual Studio doesn't have "/O3" argument they say they are using for cl.exe.A shot in the dark: Their benchmarking uses a very simple "random" generation to choose the index to search for, which is actually just a linear scan (modulo the size of the array). Could it be that with the larger array, the generated sequence of test indices happens to work nicely with the CPU's branch prediction - after all, your results show a performance drop-off for the versions that use conditional moves, and the behavior depends suspiciously on the CPU microarchitecture.
The "optimized" versions here all make more assumptions on the iterator, starting from operator+(int) in Version 1, so it no longer works on iterators with just "forward_iterator_tag". Further versions even restrict vector sizes (albeit to a very high number) and assign -1 to an unsigned integer (size_t). So this is something you can use in your project if you need the performance, but can't put it into GCC.
Funnily enough, the HTML title behind the link is indeed "Performance Engineering at Realm: How we optimized binary search".
Using -1 for size_t is sometimes even prefered to assign it's maximum value, since there is no cross-platform #define for it (most use either SIZE_T_MAX or SIZE_MAX).
In fact llvm's libc++ uses it for it's std::numeric_limits implementation and for std::string's npos.
The difference between those is only "textual solution" VS "mathematical solution" and thus a matter of personal preference.
size_t probe = (low + high) / 2;
may overflow?They are using signed integers as their indices, which means that the signed bit is always 0. Thus the addition after casting to unsigned will never overflow, and you can divide by two (shift by 1) and then recast to a signed integer, no harm no foul.
In other words, C allows that UINT_MAX == INT_MAX, in which case you will overflow.
If they made that assumption, they should explicitly mention it, but they didn't.
> Update 17 Feb 2008:... ...Now that we've made this change, we know that the program is correct;)
It seems the article is aware of the irony. Another update would be in order.
Also I'm not trying to mislead people into thinking that its a good way to implement this. The confounding bit from the article is they started in Java and ended up in C. If you were indexing with signed ints in C, C++, or any language that has unsigned integers then you already have a bug with or without the bad mean check.
suppose low is M-3 and high is M-1 (where M is 2^[#bits]) then mid should be M-2. but (M-3 + M-1) is (modulo M) equal to M-4. and half that is M/2 - 2, which is a lot less than the correct M-2. (pretend it's 2 bit unsigned ints so M is 4)
the correct 'mid' computation is low + (high - low)/2.
In practise that's not possible if it contains 32-bit integers because that would take up a 16 GB linear address space which doesn't exist on a 32-bit machine.
An academically correct version would be `size_t probe = low + ((high - low) / 2);` but that would be much slower.
You could instead just add an initial debug-mode-only assert on the list length, for correctness.
template <class T> INLINE size_t fast_upper_bound5(const vector<T>& vec, T value)
{
size_t index = 0;
size_t size = vec.size();
while (size > 0) {
size /= 2;
size_t probe = index + size;
if (vec[probe] <= value)
index = probe + 1;
}
return index;
}
Slightly faster with gcc: $ g++-mp-4.9 -std=c++11 -O3 -DNDEBUG -o blog blog.cpp
$ ./blog
size = 8192:
stl : 144.883 miliseconds
version 1: 145.406 miliseconds
version 2: 129.713 miliseconds
version 3: 109.231 miliseconds
version 4: 103.578 miliseconds
version 5: 102.282 miliseconds
But sucks with clang, apparently because of the described problem with non-using cmov instruction. $ clang++-mp-3.6 -std=c++11 -O3 -DNDEBUG -o blog blog.cpp
$ ./blog
size = 8192:
stl : 147.466 miliseconds
version 1: 145.978 miliseconds
version 2: 145.547 miliseconds
version 3: 113.546 miliseconds
version 4: 106.968 miliseconds
version 5: 144.231 milisecondsThe most educational bit for me was the careful structuring and eventual elimination of the if/else to shake out a conditional move rather than an unpredictable branch.
Modern optimizers and CPU scheduling engines are so powerful, a lot of received wisdom on how to code for speed is outdated; manual loop unrolling, for example, is rarely very beneficial. It's nice to see that there's still some room for craftsmanship in the most critical of paths. Structuring loops for autovectorization is another useful habit to get into.
I wonder how realistic their test were, and if the same results would be achieved with some cache trashing.
I'm not familiar enough with Intel's architecture to know one way or the other, but it wouldn't surprise me if not mispredicting the next memory access saves you more than just some pipeline flushing: The CPU could speculatively issue the load you don't actually need, wasting resources that could be used to service the correct addresses.
Or did you mean something else?
And other_low is a variable, and not an arbitrary element of the list (which is a big difference with respect to cache), and it can also be seen from the assembly that it's stored in register. So there there is no "both cache lines" to fetch anything from.