Every way to branch or choose a value
Compare and branch, compare-and-branch in one instruction, cmov/csel/setcc without branching, a jump table for a switch, and lane masks and blends inside SIMD. Predictability and the number of cases decide.
Ask what is being chosen and how predictable the choice is.
- Run different code depending on a test → a conditional branch:
cmp/testthenjccon x86-64,cmpthenb.condon ARM64, or one compare-and-branch (blt,bltu, … on RISC-V;cbz,cbnz,tbzon ARM64 when testing for zero or one bit). Pick the signed or unsigned condition from the C++ types. - Choose between two values and the test is hard to predict
(random data) → branchless:
cmovcc/setccon x86-64,csel/csinc/cseton ARM64, a mask-and-blend on RISC-V. Both values are computed either way, so this loses when one side is expensive or the branch is predictable. - Many cases of one integer (a dense
switch) → a jump table: one bounds check and one indirect jump. - A different target known only at run time (virtual call, function pointer) → an indirect call through a register or a vtable.
- The choice is made per element of a vector → no branch at all: a SIMD compare makes a lane mask, then a blend (or AVX-512 / SVE predicate) picks per lane.
When in doubt, write the plain if, let the compiler choose, and
measure with real data: branch prediction makes most predictable
branches nearly free.
Branch on the flags (or on two registers)
cmp and test set the flags and keep no result.
jl against jb, b.lt against b.lo, blt against bltu.
Formal. After a compare of a with b (a − b):
| C++ test | signed x86-64 | unsigned x86-64 | signed ARM64 | unsigned ARM64 | RISC-V (signed / unsigned) |
|---|---|---|---|---|---|
| a == b | je |
je |
b.eq |
b.eq |
beq |
| a != b | jne |
jne |
b.ne |
b.ne |
bne |
| a < b | jl (SF ≠ OF) |
jb (CF = 1) |
b.lt (N ≠ V) |
b.lo (C = 0) |
blt / bltu |
| a <= b | jle |
jbe |
b.le |
b.ls |
bge b, a / bgeu b, a |
| a > b | jg |
ja |
b.gt |
b.hi |
blt b, a / bltu b, a |
| a >= b | jge |
jae |
b.ge |
b.hs |
bge / bgeu |
Signed conditions use the words less/greater (l, g, lt, gt); unsigned
ones use below/above (b, a) on x86-64 and lower/higher (lo, hi, ls, hs) on
ARM64. RISC-V's bgt, ble, bgtu, bleu are pseudo-instructions that
swap the operands.
In plain English. The compare is the same either way; the branch decides whether the bits are read as signed or unsigned numbers. "Less" and "greater" mean signed; "below", "above", "lower" and "higher" mean unsigned. Equal and not-equal don't care.
Example. Compare −1 with 1. As signed, −1 < 1, so jl / b.lt /
blt are taken. As unsigned, −1's pattern is the largest value, so jb
/ b.lo / bltu are not taken.
Why this rule exists. Two's complement lets one subtractor serve both kinds of number, so the ISA moves the signed-or-unsigned choice to the condition. The compiler picks it from the C++ types being compared.
Section
Translating if/else
The compiler branches on the opposite condition, around the then-part.
A compare and a branch in one instruction, for zero tests and single bits.
Choose a value without branching
Section
Branchless selection
cmov, setcc, csel, csinc, and the mask trick on RISC-V.
Example
mov eax, esi; cmp edi, esi; cmovg eax, edi is max(a, b) for int with no branch.Example
cmp w0, w1; csel w0, w0, w1, gt is the same max on ARM64.Section
Branchless code revisited
When branchless wins (unpredictable data) and when it loses (predictable branches, expensive sides).
What a wrong guess costs, which is what branchless code is buying back.
Many targets
Section
switch and jump tables
A dense switch becomes a bounds check and one indirect jump.
Section
Virtual functions and vtables
An indirect call through a table chosen by the object's type.
Formal. For a call p->f(args) to the virtual function in vtable slot k, through a pointer p to a polymorphic object (vptr at offset 0, 8-byte slots):
- vptr ← [p] (load the object's vtable pointer);
- target ← [vptr + 8k] (load slot k);
- call target indirectly, with p passed as the hidden this argument.
x86-64 (System V): mov rax, [rdi] then call [rax + 8*k].
ARM64: ldr x8, [x0], ldr x8, [x8, #8*k], blr x8. RISC-V:
ld t0, 0(a0), ld t0, 8*k(t0), jalr t0.
In plain English. Each object carries a hidden pointer to its class's table of function addresses. A virtual call reads that pointer, reads the right entry from the table, and calls whatever address it finds, so the object's real class decides which function runs.
Example. Base Shape declares virtual area() in slot 0 and
virtual name() in slot 1. For s->name() with s in rdi, the compiler
emits mov rax, [rdi] and call [rax + 8]. A Circle and a Square
share this code but hold different vptrs, so different functions run.
Why this rule exists. The compiler cannot know an object's real class at a call through a base pointer. One table per class plus one pointer per object makes the run-time choice cost two loads and an indirect call.
Per lane, inside a vector
A vector compare writes a mask, not flags; a blend picks per lane.
Section
AVX-512 and masked operations
Mask registers make every instruction conditional per lane.