Skip to content

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