Hoare’s Rebuttal and Bubble Sort’s Comeback
blog.reverberate.org
blog.reverberate.org
This didn't need to be a surprise! It was a commonplace bit of wisdom in the microcomputer era, when sorting always came with questions about the application. You can't beat it for locality— but you also can't beat it for mostly-sorted, small collections.
Rather than sorting arrays, the area where bubblesort shines (ok. where bubblesort can still be considered!) is keeping linked lists sorted, especially where the sort order is important rather than critical. So event queues: you want to float the highest priority to the top, but it's ok if occasionally #2 is launched before #1, because events aren't guaranteed an execution order. We're using an intrusive linked list because the scheduler has to take what it's given, it can't lay out memory.
Also, any time spent tinkering with an event queue is wasted time. So every round, walk the event queue and perform one (1) bubble sort. This takes the time it takes to traverse the list, effectively. So if you've placed exactly one event at the end of the queue (head of the list), it ends up precisely where it should be. Two? You leave one of them behind for the next pass.
It's easy to reason out that when the sort starts performing badly, the problem you have is event congestion, not a poor big-O complexity for your queue sort.
Bubblesort is sometimes treated as a pessimal joke like bogosort, it isn't, it's just that the reason it's treated as a basic sort pedagogically has been lost, as the profession's center of gravity moves away from these kinds of system-level constructs.
p += (*it < pivot)
Does that really guarantee that it is branchless? Surely a compiler is free to turn that into a comparison and jump?Or, to put it another way, the compiler was also free to turn the original 'if/then' structure into branchless code using conditional instructions. My point is, surely neither version guarantees that the generated code is branchless, it's all still up to the compiler.
So a complement to the likely/unlikely annotations?
It probably depends on a core language accommodation because bitwise-swapping objects, or even primitive elements of objects, with constructors is UB.
Arthur O'Dwyer's P1144 https://wg21.link/p1144 explores that territory too.
Gcc has weird limits on generating CMOV instructions to implement this sort of optimization.
Because in branchless quicksort, no matter how long it takes to figure out where the element has to go relative to the pivot it doesn't obstruct the CPU's ability in this algorithm to start working on the next element the next cycle.
But running industrially important generic algorithms twice as fast as C++, automatically and without heroics, would be a Feather in Rust's Cap, and a Black Eye for C++. ("!!!!", as it were.) Or vice versa. Best would be if both got it, of course.
At present, both languages need heroics. I don't know of any other language where there is a reasonable prospect of getting such an optimization implemented generically, but that doesn't mean there aren't any.
cmp [ecx], eax
adc edi, 0
(Figuring out how the above works is left as an exercise for the reader.)This article is about algorithms, not compilers, and cares only about what the actual instructions are, so it’s better to treat it as if you have a compiler that will just do what you mean instead of trying to be clever (because in this instance that “compiler” is the reader). You still need to get your actual compiler to generate the right code, but that’s something to worry about later.
That is true, and the article mentions that GCC did not want to generate branchless code in this case, and thus performed much worse. Only Clang reliably generated the branchless code, and thus Clang was used for all of the benchmarking.
> the compiler was also free to turn the original 'if/then' structure into branchless code using conditional instructions
If we were just talking about:
if (*it < pivot) p++;
then I would agree with you. Optimizing this into the branchless version is a relatively trivial operation: p += (*it < pivot);
However the actual code was: if (*it < pivot) {
std::swap(*it, *p); // Could be a self-swap
p++;
}
This std::swap() line performs two memory reads and a memory write, but only if the condition is true. Eliminating the branch in this case will cause these memory operations to happen unconditionally, which means the optimizer will actually be introducing extra memory operations. This is the opposite of what you generally expect an optimizer to do: if anything you usually want an optimizer to reduce the number of memory operations being performed! The compiler would need to be extremely confident that this will somehow speed up the program overall, and it will also need to guarantee that none of these new loads/stores can possibly introduce memory errors (out-of-bounds reads or writes).For these reasons, I don't expect real compilers to ever translate the example above into the equivalent branchless code, as given in Andrei's article:
auto x = *read;
auto smaller = -int(x < pivot);
auto delta = smaller & (read - first);
first[delta] = *first;
read[-delta] = x;
first -= smaller;You can look this advice up in a raft of 1990s books when we were implementing our own 3d, eg Michael Abrash's Graphics Programming Black Book
http://www.codercorner.com/RadixSortRevisited.htm
> In every decent programmer’s toolbox lies a strange weapon called a Radix Sort. Where does it come from ? Who invented it ? I don’t know. As far as I can remember it was there, fast, easy, effective. Really effective. So unbelievably useful I’ve never really understood why people would want to use something else. The reasons ? Most of the time, they tell me about floats, negative values, and why their new quick-sort code rocks.
> Enough, I’m tired. Although the standard Radix Sort doesn’t work very well with floating point values, this is something actually very easy to fix. In this little article I will review the standard Radix Sort algorithm, and enhance it.
This is basically a bullshit post.
There are references to a few papers in the README.
Just to be clear if you keys don’t duplicate then you are not resolving a sorting problem per se. It’s just a degenerate case of the problem which is obviously easy to resolve.
Btw, I'm surprised nobody has objections about constants. The constants for the type of stuff 'discrimination' does are (apparently) really bad. Like really bad.
^ The above was a bit sarcastic. I know you're talking about straight up performance, but the problem there wouldn't be the Schwartzian Transform.
Isn't it curious how often people with the most strongly voiced opinions ("bullshit post") are bullshitters themselves? There's some interesting dynamic going on, psychologically.
90% of it is discussing the instruction complexity of it. Which is not the same as “informational complexity”
Just to be clear it’s information-theoretically impossible to beat nlogn complexity.
The C++ standard requires that std::sort work for "move-only types", so you can't copy the pivot. I suspect this is where quite a bit of the slowdown happens -- as the pivot has to be taken by reference/pointer, the compiler optimises worse.
Could std::sort implementations optimise for when the pivot can be copied? Certainly! Particularly when it is a "small value" like this. I worked on this years ago for libstdc++ (g++'s std::sort), but it turned out to make the code really horrible, so it was never merged in.
std::sort implementations already use an alternative sort (usually insertion sort) for small sized lists.
Do you mean internally/due to subtle complexity requirements? Cause std::sort works just fine on copyable types like integers and complexity is only defined in terms of swaps.
You could in principle write two implementations, one that moves + swaps, and one that moves, swaps and (occasionally if it really wants to) copies, but it's extremely hard (possibly impossible) to decide when it is "safe" to use the std::sort that can copy.
It turns out it is much easier to optimise if you can copy the pivot into a local variable, but that's hard to do in std::sort.
Here is an implementation. This should work with all types. Unfortunate I had to annotate with restrict, but a small price to pay.
https://easylang.online/blog/qsort_c.html
My implementation is pretty fast. At a size of 40 or 50, I switch to Insertion sort, and there the branchless bubblesort is significantly slower (I just tried it).
But I have to admit defeat to this Sample sort:
Stop teaching bubble sort.
[1] - https://en.wikipedia.org/wiki/Sorting_network#Insertion_and_...
On very small arrays bubble sort is faster than quicksort as quicksort has a large constant overhead.
Constant overheads are usually disregarded in complexity analysis but in practice, it can be significant.
Question: Does it always do less swaps? Probably yes, I think.
> Most contemporary implementations of quicksort use Hoare partition, for obvious reasons: it does as many comparisons as the Lomuto partition and fewer swaps.
> Given that Hoare partition clearly does less work than Lomuto partition, the question would be why ever teach or use the latter at all. […] implemented in a branch-free manner, Lomuto partition is a lot faster than Hoare partition on random data.
This is not the case when insertion sort is expressed as a sorting network. Take a look at this image [1]: the final "insert" has comparators that go through the entire network. The "algorithm" version wouldn't do that: it would stop (using a branch) as soon as the entry hits the correct place. If you remove that branch (which, remember, is the thing that makes insertion sort faster in the general case), insertion and bubble sort are indeed very similar.
The point here is that "sorting algorithms" and "sorting networks" are not the same thing: a sorting algorithm can branch, a sorting network cannot. The same principles of bubbling and insertion can apply to both, but that does not mean that an insight that applies to sorting networks necessarily applies to sorting algorithms.
[1]: https://en.wikipedia.org/wiki/Sorting_network#/media/File:Re...
(PS: I actually wrote most of that wikipedia article years ago! I did the illustrations too! I basically just wrote down and made diagrams for whatever I could understand from The Art of Computer Programming vol 3. The insight about how bubble sort and insertion sort are essentially the same when expressed as sorting network is from Knuth, though I doubt it's original with him)
I still dont know why quicksort is so common given its worst case performance.
In particular, the author claims (and presents benchmarks that appear to verify) that for sorting very small arrays, bubblesort is better than insertion sort because you can do it with fewer unpredictable branches, and unpredictable branches are very bad for performance.
Maybe the same optimizations can be applied to insertion sort, but it's not obvious to me that they can; in particular, the inner loop of insertion sort is of variable length and necessarily involves a hard-to-predict branch. You could do away with that by always going all the way to the end of the array, but unless I'm confused this gives you an algorithm pretty much equivalent to bubblesort, and equally worthy (according to the usual sorts of analysis) of being called "usually the worst in every conceivable way".
I have battled so many times with smug novice "engineers", fresh from their CS courses, pointing, without even thinking twice, O(n^2) or even O(n^3) algorithms as errors on code reviews. This without taking into account the size of the input or the underlying machine executing the code.
It is my constant source of amazement -- to interview yet another candidate who can write any number of sorting algorithms from memory on a whiteboard but who can't tell whether a linked list or an array list insertion is going to be faster for a given application.