Compiler codegen luck โ a cosmetic edit that ran 6x faster
- title
- Compiler codegen luck โ a cosmetic edit that ran 6x faster
- type
- summary
- summary
- A one-line C rewrite (*p++ = x vs *p = x; p++;) flips Clang between a branch and a branchless csel/cmov, changing quicksort speed 6x
- tags
- performance, compilers, microarchitecture, c
- sources
- lucky-code
- created
- 2026-07-18
- updated
- 2026-07-18
Christof Kaser was tuning a branchless quicksort in C and hit a case where two
logically identical versions of the same hot loop ran more than six times apart.
Sorting 50 million doubles on an M1 with Clang at -O3 took 4.39 seconds one
way and 0.70 seconds the other. The faster version also beat std::sort (1.33s)
by almost 2x. The algorithm, the data, and the compiler flags were all the same.
The only change was how the partition loop was written.
The change
The partition step walks the array and sends each element to one of two write pointers depending on how it compares to the pivot. The explicit form spells out the pointer bump on its own statement:
if (BLQS_CMP(x, piv)) { *lwr = x; lwr++; }
else { *rwr = x; rwr--; }
The idiomatic form folds the store and the increment together:
if (BLQS_CMP(x, piv)) *lwr++ = x;
else *rwr-- = x;
Same effect, same semantics. But Clang compiles them differently. The compact
form becomes a branchless loop built on csel on AArch64 (cmov on x86): the
comparison feeds a conditional select that picks the destination pointer and the
increment, then does one unconditional store. The explicit form keeps a real
conditional branch around the store. GCC ignores the distinction and emits the
slower branch version both ways.
Why the wording matters to the compiler
The mechanism was pinned down in the HN thread. In the one-statement version,
the last operation in each branch of the LLVM IR is the store. LLVM's
SimplifyCFG pass looks for stores that are identical except for one operand,
hoists them into a shared successor block, and replaces the choice with a
select instruction โ which later lowers to a conditional move. In the
two-statement version, the last operation in each branch is the pointer
arithmetic, not the store. The two pointers differ by two operands (different
base pointers, one incremented and one decremented), so SimplifyCFG bails out
immediately and never reaches the stores. Inspecting the pipeline in Compiler
Explorer shows the same split: after conversion to SSA the fast version computes
the pointer increment before the store, the slow version does it the other way,
and that ordering is enough to defeat the pattern match. Nothing in the source
tells you which side of that line you land on.
Why branchless wins here specifically
A conditional move is not automatically faster than a branch. It is faster only
when the branch is hard to predict. Quicksort partitioning on random data is
close to the worst case for a branch predictor: roughly half the elements go
each way with no pattern, so the predictor is wrong about half the time and
every misprediction flushes the pipeline. csel/cmov has no branch to
mispredict โ it always does the same work โ so it wins when the data is
unpredictable and loses when the branch would have been predictable. The
compiler cannot know the data distribution at compile time, so it guesses. On
this workload the branchless guess happens to be right, which is where the
"luck" comes from: the fast path exists only because a cosmetic edit nudged the
compiler into a pattern that suited the runtime data. See conditional-move
for the general tradeoff. Clang exposes __builtin_unpredictable() to hint the
branch is a coin flip, but that only helps if you already know to reach for it.
The wider point
The takeaways people drew from the thread are the useful part. Compiler
optimizers recognize specific IR shapes, so writing idiomatic code raises the
odds a pass fires โ but the matching is brittle and undocumented, and
semantically identical code can land on either side of it. For code that has to
be fast, memory locality, vectorization, and branch behavior usually matter more
than algorithmic complexity, and none of them are visible in the source. The
recurring advice: measure, read the generated assembly (Compiler Explorer,
perf), and don't trust superstition about what "should" be fast.
This sits alongside other cases in this vault where two builds of the same code diverge for reasons the source never shows: linux-7-postgres-regression, where dropping a kernel preemption mode let a PostgreSQL spinlock holder get preempted mid-page-fault and halved throughput, and huge-pages, where page size alone changes fault count and TLB pressure by orders of magnitude. In each, the performance lives below the code you wrote.