What is the difference between `__builtin_expect` (C10_LIKELY) and `[[likely]]`?
An answer to this question on Stack Overflow.
Question
PyTorch contains these macros which use __builtin_expect:
#define C10_LIKELY(expr) (__builtin_expect(static_cast<bool>(expr), 1))
#define C10_UNLIKELY(expr) (__builtin_expect(static_cast<bool>(expr), 0))
Apart from the fact that __builtin_expect is compiler specific,
what is the difference between these and C++20's [[likely]] and [[unlikely]] attributes?
Answer
Quick Answer: Expressivity
The difference is in the degree of expressivity. With __builtin_expect we can write:
if (LIKELY(x()) && UNLIKELY(y())) {
while with [[likely]] we can only write
if (x && y) [[likely]] {
Does it matter?
Yes.
Let's construct two programs to explore the difference.
Using builtin
// builtin.cpp
// Method A: per-operand hints via __builtin_expect.
// Opaque predicates + sinks so nothing is folded away and both
// short-circuit branches must be emitted as real branches.
extern bool x();
extern bool y();
extern void body();
extern void keep_going();
#define LIKELY(expr) (__builtin_expect(static_cast<bool>(expr), 1))
#define UNLIKELY(expr) (__builtin_expect(static_cast<bool>(expr), 0))
// "x almost always true, y almost always false"
void hint() {
#ifdef LIKELY_FIRST // For exploring orderings
if (LIKELY(x()) && UNLIKELY(y())) {
#else
if (UNLIKELY(x()) && LIKELY(y())) {
#endif
body();
}
keep_going();
}
// Control: single condition.
void single() {
if (LIKELY(x())) { body(); }
keep_going();
}
Using [[likely]]
// likely.cpp
// Method B: statement-level hint via the standard [[likely]]/[[unlikely]] attribute.
// Opaque predicates + sinks so nothing is folded away and both
// short-circuit branches must be emitted as real branches.
extern bool x();
extern bool y();
extern void body();
extern void keep_going();
// only says "the taken branch (body) is unlikely"
void hint() {
if (x() && y()) [[unlikely]] {
body();
}
keep_going();
}
// Control: single condition.
void single() {
if (x()) [[likely]] { body(); }
keep_going();
}
Compiling and comparing
We can compile to assembly
clang++ -O2 -std=c++20 -S -o builtin_likely_first.s builtin.cpp -DLIKELY_FI
RST
clang++ -O2 -std=c++20 -S -o builtin_unlikely_first.s builtin.cpp
clang++ -O2 -std=c++20 -S -o likely.s likely.cpp
when we compare
diff builtin_likely_first.s likely.s
There's not a meaningful difference.
However, when we compare builtin_unlikely_first, we find the following:
# diff -y --width=80 --expand-tabs builtin_unlikely_first.s likely.s
# LEFT = UNLIKELY(x()) && LIKELY(y()) RIGHT = x() && y() [[unlikely]]
# gutter: | changed < left only > right only (blank = same)
.file "builtin.cpp" | .file "likely.cpp"
.text .text
.globl _Z4hintv .globl _Z4hintv
.p2align 4 .p2align 4
.type _Z4hintv,@function .type _Z4hintv,@function
_Z4hintv: _Z4hintv:
.cfi_startproc .cfi_startproc
# %bb.0: # %bb.0:
pushq %rax pushq %rax
.cfi_def_cfa_offset 16 .cfi_def_cfa_offset 16
callq _Z1xv callq _Z1xv
testb %al, %al testb %al, %al
jne .LBB0_1 | je .LBB0_3
> # %bb.1:
> callq _Z1yv
> testb %al, %al
> jne .LBB0_2
.LBB0_3: .LBB0_3:
popq %rax popq %rax
.cfi_def_cfa_offset 8 .cfi_def_cfa_offset 8
jmp _Z10keep_goingv jmp _Z10keep_goingv
.LBB0_1: | .LBB0_2:
.cfi_def_cfa_offset 16 .cfi_def_cfa_offset 16
callq _Z1yv <
testb %al, %al <
je .LBB0_3 <
# %bb.2: <
callq _Z4bodyv callq _Z4bodyv
popq %rax popq %rax
.cfi_def_cfa_offset 8 .cfi_def_cfa_offset 8
jmp _Z10keep_goingv jmp _Z10keep_goingv
.Lfunc_end0: .Lfunc_end0:
.size _Z4hintv, .Lfunc_end0- .size _Z4hintv, .Lfunc_end0-
.cfi_endproc .cfi_endproc
.globl _Z6singlev .globl _Z6singlev
.p2align 4 .p2align 4
.type _Z6singlev,@function .type _Z6singlev,@function
_Z6singlev: _Z6singlev:
.cfi_startproc .cfi_startproc
# %bb.0: # %bb.0:
pushq %rax pushq %rax
.cfi_def_cfa_offset 16 .cfi_def_cfa_offset 16
callq _Z1xv callq _Z1xv
testb %al, %al testb %al, %al
je .LBB1_2 je .LBB1_2
# %bb.1: # %bb.1:
callq _Z4bodyv callq _Z4bodyv
.LBB1_2: .LBB1_2:
popq %rax popq %rax
.cfi_def_cfa_offset 8 .cfi_def_cfa_offset 8
jmp _Z10keep_goingv jmp _Z10keep_goingv
.Lfunc_end1: .Lfunc_end1:
.size _Z6singlev, .Lfunc_end .size _Z6singlev, .Lfunc_end
.cfi_endproc .cfi_endproc
.ident "clang version 21.1.8 .ident "clang version 21.1.8
.section ".note.GNU-sta .section ".note.GNU-sta
.addrsig .addrsig
Let's focus on the key changes. This section
testb %al, %al testb %al, %al
jne .LBB0_1 | je .LBB0_3
> # %bb.1:
> callq _Z1yv
> testb %al, %al
> jne .LBB0_2
shows that after testing x(), the two versions part ways.
On the left (UNLIKELY(x()) first), the whole fast path is the single instruction jne .LBB0_1. Since x is expected to be false, "x is false" just falls straight through
toward keep_going(), and the code only jumps away, to a separate block, in the rare case that x is true. y() isn't here at all (it's been moved farther down).
On the right ([[unlikely]]), after testing x, y() is also called and tested (callq _Z1yv -> testb), so y() stays on the path.
The next section is
.LBB0_1: | .LBB0_2:
.cfi_def_cfa_offset 16 .cfi_def_cfa_offset 16
callq _Z1yv <
testb %al, %al <
je .LBB0_3 <
# %bb.2: <
On the left we have the section the previous left-side section jumped to. y() is compared here in the rare case that's necessary (callq _Z1yv -> testb.
On the right the matching block has none of those lines because y() was already evaluated further up.
What difference does it make?
Modern CPUs don't just execute instructions one at a time. They have many layers of complexity to improve code performance and these layers interact with how we write code.
The instruction cache and the front-end. The CPU fetches instructions in contiguous chunks (cache lines, ~64 bytes). Instructions that sit next to each other in memory get fetched together "for free." Placing the unlikely y() and body() in a separate block way at the end of the function means that the most likely path of execution is pulled into cache and then used. In contrast, if unlikely y() follows likely x() directly then cold code evicts the useful hot code. Rearranging code so this doesn't happen is called hot/cold splitting and means fewer cache lines are touched on the hot path and better use is made of the instruction cache.
Branch prediction and fall-through. The CPU attempts to predict whether a branch will be taken or not (so it can prefetch and do speculative execution). If the CPU predicts wrong this work is wasted and a full pipeline flush may be necessary.
Compilers arrange code so the expected path is the fall-through (no jump) and the unexpected path is the one that jumps away. Note that putting UNLIKELY above meant that the hot path has one fewer branch prediction then the when we used [[unlikely]]. That gives the CPU a better shot at getting things right, in addition to the instruction cache benefits.
As a caveat, a person can really overthink this. You'd only notice the difference between these in a really hot path. If this is something you actually worry about it would be better to use profile-guided optimization (PGO): the compiler can measure actual branch frequencies and reorganize code accordingly, ignoring hints. This is much better than using hints. So hints matter most in situations where we can't profile and suspect a hot path.
Branch-hint layout matrix (clang 21, -O2)
We can explore this for all orderings and logical operators by evaluating
void f() {
if ( <cond over x() and y()> ) { body(); }
keep_going();
}
And we find that && and || will short-circuit. In contrast, ^ never short-ciruits (both x() and y()) always run, so per-operand hints can't steer it.
Legend: "body usually runs taken?" = is the body the common case? "cold tail" = pushed to an
out-of-line block. "inline" = on the straight-line fast path. "always" = the
body() call is emitted unconditionally (no short-circuit).
| Expression | body usually runs? | what happens at the x test |
body() |
y() |
|---|---|---|---|---|
LIKELY(x) && UNLIKELY(y) |
no | je away if x false; fall through to eval y(); jne to body |
cold tail | inline |
UNLIKELY(x) && LIKELY(y) |
no | jne away to eval y()+body(); fall through to keep_going() |
cold tail | cold tail |
x && y [[unlikely]] |
no | je away if x false; fall through to eval y(); jne to body |
cold tail | inline |
x && y [[likely]] |
yes | je away if x false; fall through to eval y(); body() inline |
inline | inline |
LIKELY(x) \|\| UNLIKELY(y) |
yes | je away to eval y(); fall through to body() inline |
inline | cold tail |
UNLIKELY(x) \|\| LIKELY(y) |
yes | jne away to body(); fall through to eval y() |
inline | inline |
x \|\| y [[likely]] |
yes | jne away to body(); fall through to eval y() |
inline | inline |
x \|\| y [[unlikely]] |
no | jne away to body() (cold); fall through to eval y(); fall to keep_going() |
cold tail | inline |
LIKELY(x) ^ UNLIKELY(y) |
hints ignored* | eval x(), eval y(), cmpb; je skip; body() inline |
inline | always |
UNLIKELY(x) ^ LIKELY(y) |
hints ignored* | eval x(), eval y(), cmpb; je skip; body() inline |
inline | always |
x ^ y [[likely]] |
yes | eval x(), eval y(), cmpb; je skip; body() inline |
inline | always |
x ^ y [[unlikely]] |
no | eval x(), eval y(), cmpb; jne to body() (cold); fall to keep_going() |
cold tail | always |