Beating Bisect with Branchless Binary Search
github.com
github.com
Sinilarly, what's going on with the performance of bisect_left_c? (Second graph.) Why is the graph completely flat at first, only to then ramp up with a perfectly straight line? If you add some measurement points in between, will it turn out the high measurement on the right of the graph is actually a measurement error?
If this works it would make a nice python lib.
Also, if you want to, the repo provides a build script for the C-extension so you can mess around with the benchmarks. I would love for you to find some bugs in my implementation :).
> I experimented with using the matplotlib `plt.xscale("log")` function, but because the x axis already distributed by powers of 2, [...]
I said y, not x ;)
Also, the x axis is not distributed by powers of 2, right? I see 0 through roughly 2.7*10^8 — either that or the axis is confusingly labeled. :)
I would want two graphs here: 1. High-res linear-linear to show the expected logarithmic behaviour (surely binary search is logarithmic, right?), as well as possibly some performance cliff as you run out of cache and start hitting RAM for a decent part of the search. 2. Linear x but logarithmic y, on which you compare the various implementations. The point is that because all implementations presumably have the same complexity, and furthermore probably have caching issues starting from similar (though not necessarily equal) points, the most interesting point of info is their _ratio_. And since log(2*f(x)) = log(2) + log(f(x)), the ratio between two functions is simply their _distance_ on an yscale("log") plot. Much easier to see, especially if the ratio is like 100x.
Perhaps you'll even see that for sufficiently large inputs, the ratio shrinks because the search becomes memory-bound. :)
sizes = [2**i for i in range(1, 29)]
The limits that matplotlib fills in are largely superficial, the benchmarks and the x axis are only being evaluated at powers of two.Your points about the other two graphs though is very good. I think my graphs could use some clarity. The main reason why I didn't use a log scale and compute on much much larger inputs is mainly because it would just take too long for my laptop to compute.
Now for binary search that effect will be smaller, but still, at powers of two you might more quickly get that consecutive probes have the same remainder modulo the number of cache sets, meaning they get put in the same cache set and hence conflict much harder than one might expect.
All that to say: more samples, in between the ones you already have, would be quite nice. Especially on random input sizes (but _also_ on round numbers!), just to rule out any of the above-mentioned shenanigans. (I particularly suspect the large 2^28 sample as perhaps falling prey to this, but perhaps not.)
EDIT: I dug up my graph, it's here: https://tomsmeding.com/f/matmul-ca3-graph.jpg . CPI = cycles/instructions. Look at the beauty.
[0] https://probablydance.com/2023/04/27/beautiful-branchless-bi...
Beautiful branchless binary search - https://news.ycombinator.com/item?id=35737862 - April 2023 (143 comments)
Oh the irony, considering that fibonacci hashing is another of those forgotten algorithms, see yesterday's discussion: https://news.ycombinator.com/item?id=35739629
Is the value being plotted the average time per execution? You ran each test scenario for a while, not once, I assume.
It's also worth considering whether you should measure against the same dataset and value every time, or have a bunch of different ones. If it's the same dataset and value it's possible you're priming the branch predictor tables, or worse, favoring one algorithm by accident due to the layout of the data.
https://github.com/numpy/numpy/blame/main/numpy/core/src/npy...
EDIT: issue is fixed now I think
begin += (arr[step+begin] < value)?step:0;
Something like: int mask = ((arr[step + begin] - value) >> 31); //Depends on signed shift
begin += (step & mask) | (0 & ~mask);
(Obviously in this case, it's simplifiable.) for (step >>= 1; step != 0; step >>=1) {
if ((next = begin + step) < size) {
begin += PyObject_RichCompareBool(PyList_GetItem(list_obj, next), value, Py_LT) * step;
}
}If there's no calculation being done, it'll simplify.
value = (test) ? const0 : const1;
But if calculations are being done, it won't. value = (test) ? calc0() : calc1();
If you want non-branching where the ternary options are calculated, you need to calculate both.This matters most with SIMD operations.
Look at section 2.5.1 (Branch-Equivalent SIMD Processing)
http://ftp.cvut.cz/kernel/people/geoff/cell/ps3-linux-docs/C...
There's probably something at the instruction level which allows the constant ternary expressions to be non-branching.
Even removing the UB with something like __builtin_sub_overflow(), if arr[...] is INT_MAX and value is -2, then the difference will have the high bit set!
I haven't tried it, but this should compile to a cmove or seta, not a jump:
int cond = arr[step+begin] < value;
begin += step & -cond; int mask = -(arr[step+begin] < value);
int value = (val0 & mask)|(val1 & ~mask);