Part 1 · The register machine
Control flow is compare-and-jump
There are no booleans and no if statements. There is a register of flag bits, and instructions that read them — and sometimes the branch disappears entirely.
What this lesson is trying to break (3)
- A comparison produces a boolean value.
- Every `if` in the source becomes a branch in the output.
- Branchless code is always faster.
There is no if at this level, and there are no booleans. There is a small
register of individual bits, and a set of instructions that consult it.
The flags
Arithmetic and comparison instructions set flags as a side effect. The four that matter:
| Flag | Set when |
|---|---|
| ZF zero | the result was zero |
| SF sign | the result’s top bit was set (negative, if signed) |
| CF carry | an unsigned overflow occurred |
| OF overflow | a signed overflow occurred |
cmp a, b performs a - b, throws the result away, and keeps the flags.
test a, b performs a AND b, likewise. Then a conditional instruction reads
some combination.
This is why lesson 1.2 could say that signedness lives
in the jump: jb (below) consults CF, while jl (less) consults
SF ≠ OF. Same cmp, different flags read, different type semantics.
test rax, rax · test eax, eax
A zero or null check.
rax AND rax is just rax, so this is a pure flag-setting move — it says “is
this zero, and what is its sign?” without needing a constant operand.
Followed by je/jz it is if (x == 0). Followed by jne/jnz it is
if (x). On a pointer it is a null check. This is one of the highest-frequency
patterns in all compiled code, and recognising it instantly removes a lot of
friction.
The branch that isn’t there
Now the part that breaks the naive model.
int a(int x) {
return x < 0 ? -x : x;
}An absolute value. There is clearly a conditional in the source.
No branch at all. Four instructions:
"a":
mov eax, edi
neg eax
cmovs eax, edi
retRead it carefully, because the logic is inverted from the source:
- copy
xintoeax - negate it unconditionally —
negalso sets the sign flag fromx’s original value cmovs eax, edi— “conditional move if sign”: ifxwas negative, keep what we have; otherwise overwriteeaxwith the originalx
So it computes -x always, and then conditionally throws it away. That is
the essence of branchless code: do both, discard one.
The compiler chose this because x < 0 on arbitrary input is unpredictable, and
an unpredictable branch costs roughly a full pipeline flush — fifteen-ish cycles —
while neg plus cmov costs about two.
But branchless is not free
The obvious next thought is “so always go branchless”. That is wrong, and knowing why is the useful part.
Varies: only how predictable the condition is
| Branch | cmov / csel | |
|---|---|---|
| Work done | Only the taken side | Both sides, always |
| Well-predicted condition | ~0 cycles — prediction hides it | Pays for both sides every time |
| Unpredictable condition | ~15 cycles on a mispredict | ~1–2 cycles, always |
| Data dependency | None — speculation runs ahead | Yes — must wait for the condition |
| Can it skip expensive work? | Yes | No — both sides are evaluated |
A branch predicted correctly 99% of the time is nearly free, and cmov would be a pure loss. A branch that is genuinely 50/50 is expensive, and cmov wins. The compiler guesses; profile-guided optimisation lets it know.
Two consequences worth carrying:
A cmov cannot be used if either side has side effects or might fault. You
cannot conditionally-move a memory load that would segfault, because it would be
performed. That is why if (p) use(*p) must stay a branch.
cmov creates a data dependency. The result waits on the condition, so
inside a dependency chain a cmov can be slower than a predicted branch even
when the branch occasionally misses. This is the first place fiction 2 starts
leaking, and it is dealt with properly in
4.3.
ARM64: the same idea, more of it
ARM64 calls it csel — conditional select — and it is three-operand, so it reads
better:
csel w0, w1, w2, lt ; w0 = (lt) ? w1 : w2
ARM64 also has a family of related instructions — cinc (conditional increment),
cneg, csetm, cset — so it can express more conditional shapes without
branching. Compile the abs example for ARM64 and you will typically see
something even shorter than the x86 version, because negate-and-select is a
pattern the ISA anticipates.
For each, say whether you would expect a branch or a conditional move, and why:
return x > y ? x : y;on random integersif (unlikely_error_flag) log_and_abort();if (ptr) return *ptr; else return 0;count += (c == ' ');inside a loop over English text
Number 3 has a forced answer for a reason other than prediction. Say what it is.
What you should now be able to say
- There are no booleans. Comparisons set flags; separate instructions read them.
cmpis a subtract that keeps only flags;test rax, raxis the zero/null check idiom.- Signedness lives in which flags the conditional reads.
cmov/cselmeans the compiler removed a branch, computing both sides and discarding one.- Branchless is not automatically better: it always pays for both sides, creates a data dependency, and cannot be used where a side might fault.
- ARM64’s
cselfamily covers more conditional shapes than x86’scmov.