Assembly (x86-64, AArch64, RISC-V) Module 6 – Branches, loops and jump tables
Branchless code: cmov, csel and Zicond
How cmov on x86-64, csel on AArch64 and Zicond on RISC-V pick between two values without a branch, and when branchless code hurts.
What you will learn
- Replace simple if/else assignments with conditional-select instructions on three ISAs
- Convert comparisons into 0 or 1 values with setcc, cset and slt
- Decide when branchless code helps and when it hurts
- Read a RISC-V branchless shape with and without Zicond
Before you start
What you’ll learn · Before you start
What you will learn
- Replace simple if/else assignments with conditional-select instructions on three ISAs
- Convert comparisons into 0 or 1 values with setcc, cset and slt
- Decide when branchless code helps and when it hurts
- Read a RISC-V branchless shape with and without Zicond
Before you start
On this page
On an Apple silicon Mac, max(a, b) for two signed integers takes three instructions, cmp,
csel w0, w0, w1, gt and ret, and the first two are the whole decision: a compare and a one-instruction
conditional pick, with no branch between them. The same job written as an if/else puts a conditional branch between the compare and the result,
and on random data the predictor guesses that branch wrong about half the time, at about 10 to 20 cycles a miss.
Each instruction set has its own answer for that shape: cmov on x86-64, csel on AArch64, and on RISC-V the
Zicond extension, or a mask built from base instructions on chips without it. branchless code: A pattern that computes a result without a conditional branch, using a conditional select, a conditional-zero or a mask built from a comparison, so no path is skipped and the branch predictor is never consulted. The pattern wins on unpredictable data and loses on predictable data because it always pays for both sides.
like this runs both sides of the choice on every call, so it beats the branch on some data and loses on other data;
a later section gives the rule for telling the two apart.
cmov on x86-64 and the two sides it always pays for
On x86-64, the conditional move: An instruction that writes its destination from one of two sources (or a cheap change of one, such as +1 or NOT) under a condition, without a branch. x86-64 cmovcc and AArch64 csel test the flags; RISC-V has no flags, so Zicond's czero pair tests a register, and two of them with an or make the select. is cmovcc dst, src: it copies the source into
the destination when the condition code: A named test of the flags that a conditional branch, set or select instruction uses, such as eq (Z = 1) or the signed lt (N differs from V) on AArch64, and e or l on x86-64. More in “Flags and condition codes” holds and keeps the old value otherwise.
The SDM’s description adds two details: the source is loaded on every call, whichever way the condition goes, and
a 32-bit cmov clears the upper half of the 64-bit register in both cases. It reads the flags that a previous cmp
or test left behind, so one cmp and one cmovcc are a two-register pick with no branch.
The companion is setcc: it writes 0 or 1 into a byte register for the same condition, which movzx can widen to
a Boolean the way C expects (a > b) to be.
cmovcc arrived with Intel’s P6 family, as the SDM’s CMOVcc entry records, and the x86-64 psABI lists CMOV among
the baseline features any x86-64 program may expect, so code built for x86-64 can use cmov without a feature
check, and compilers use it for short selects such as a > b ? a : b. Here is the three-instruction body for
max(a, b), in GNU as Intel syntax:
# max_int(a, b) on x86-64 in GNU as Intel syntax: three instructions in the body.
# System V AMD64: a is in edi, b in esi and the result goes in eax.
# cmovcc dates from Intel's P6 family and is in the x86-64 baseline, so every x86-64 processor has it.
.intel_syntax noprefix
.text
.globl max_int
max_int: # int max_int(int a, int b)
mov eax, esi # eax = b (the pick when b wins or they are equal)
cmp edi, esi # compare a with b, setting the flags
cmovge eax, edi # if a >= b (signed), eax = a; else eax keeps b
ret The System V AMD64 calling convention passes a in edi and b in esi. The mov loads b as the fallback
result, the cmp sets the flags and the cmovge overwrites eax with a when a >= b holds. Both the mov and
the cmov run on every call and the branch predictor is never consulted, so no
branch misprediction: A wrong guess by the branch predictor about which way a conditional branch goes: the core throws away the work done on the guessed path and refetches from the correct target, about 10 to 20 cycles on current cores. Branchless code avoids the cost by removing the branch, at the price of running both sides. can happen here: that is where cmov wins over jcc on
data that is hard to predict. The table follows eax through two calls with the first two pairs of the lesson’s
driver:
| After | max_int(3, 7) |
max_int(7, 3) |
|---|---|---|
mov eax, esi |
eax = 7 |
eax = 3 |
cmp edi, esi |
3 - 7 < 0: ge is false |
7 - 3 > 0: ge is true |
cmovge eax, edi |
no move: eax stays 7 |
moves: eax = 7 |
Both calls run all three instructions and both return 7, the same answers the AArch64 run prints below.
Putting a flag-writing instruction between cmp and cmovcc
xor eax, eax or test rax, rax between the cmp and the cmovcc overwrites the flags the compare set, so the
cmovcc reads the wrong answer. Instructions that write no flag — mov, lea, nop and the branches
themselves — are safe between the two.
Within that constraint, a cmov replaces an if/else whose two sides are values already in registers: no label, no
join point and nothing for the predictor to guess.
Key idea
A conditional-move pays for both sides of the pick every call and spends nothing on branch prediction. The win is a flat cost: no misprediction stalls on data whose shape the predictor cannot learn.
Checkpoint
Check: cmov on x86-64, and both sides always paid for
csel on AArch64 and the family around it
AArch64 names the same idea csel, conditional select. csel wd, wn, wm, cond writes wn into wd when the
condition holds and wm otherwise, with the condition read from the four flags N, Z, C and V. Because it names
both sources, it needs no fallback mov: one cmp and one csel are the whole decision.
The family around csel costs the same single instruction and adds a cheap arithmetic step to the second source:
csinc writes wn or wm + 1, csinv writes wn or ~wm, and csneg writes wn or -wm. Two aliases round
the set out: cset wd, cond writes 1 when the condition holds and 0 otherwise (it is csinc with the zero register: A register that always reads as zero and discards writes, so that 'set to zero' and 'copy from zero' reuse one move. AArch64 names it xzr/wzr; RISC-V names register x0 'zero'. x86-64 has none, so it uses xor-with-self. More in “Moving values and loading constants” twice, so 0 or 0 + 1), and csetm wd, cond writes all-ones or 0 as a
bitwise mask (it is csinv on the same pair, so 0 or ~0).
Here is the AArch64 shape for max(a, b), two instructions in the body plus the return:
// max_int(a, b) on AArch64: one cmp sets the flags and a single csel is the whole decision.
// AAPCS64: a is in w0, b is in w1 and the return value goes back in w0.
.text
.p2align 2
.globl _max_int
_max_int: // int max_int(int a, int b)
cmp w0, w1 // compare a with b, setting NZCV
csel w0, w0, w1, gt // w0 = (a > b) ? a : b, no branch between them
ret // Calls max_int (defined in max_aarch64.s) on four pairs and prints each result on its own line.
#include <limits.h>
#include <stdio.h>
int max_int(int a, int b);
int main(void) {
int pairs[4][2] = { {3, 7}, {7, 3}, {-1, 1}, {INT_MIN, INT_MAX} };
for (int i = 0; i < 4; ++i) {
int a = pairs[i][0], b = pairs[i][1];
printf("max(%d, %d) = %d\n", a, b, max_int(a, b));
}
return 0;
} import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
# Assemble, link and run the AArch64 branchless max natively on an Apple silicon Mac.
# The function body is cmp + csel + ret; csel is the whole decision.
session.run("""
clang -O1 -o max_a64 driver_max.c max_aarch64.s
./max_a64
""") Output
$ clang -O1 -o max_a64 driver_max.c max_aarch64.s $ ./max_a64 max(3, 7) = 7 max(7, 3) = 7 max(-1, 1) = 1 max(-2147483648, 2147483647) = 2147483647
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 max_session.py
The run prints the four pairs the driver asked for, including the boundary of the signed range: INT_MIN against
INT_MAX picked INT_MAX, as it should. Every one of those answers came from the same single csel; the last
section sets that body beside the other three shapes.
The second AArch64 example turns a comparison into a value with cset. is_positive(x) is one cmp w0, #0 and
one cset w0, gt. Because cset writes the whole register, the 0 or 1 is ready to return, with no movzx step
as after setcc on x86-64.
// is_positive(x) on AArch64: a cmp with zero, then one cset writes 1 when x > 0 and 0 otherwise.
// AAPCS64: x is in w0, the return value goes back in w0. No branch between the compare and the result.
.text
.p2align 2
.globl _is_positive
_is_positive: // int is_positive(int x)
cmp w0, #0 // compare x with 0, setting the flags
cset w0, gt // w0 = 1 if (x > 0), else 0
ret // Calls is_positive (defined in cset_aarch64.s) on -3, 0 and 3 and prints each result on its own line.
#include <stdio.h>
int is_positive(int x);
int main(void) {
int values[3] = { -3, 0, 3 };
for (int i = 0; i < 3; ++i) {
int x = values[i];
printf("is_positive(%d) = %d\n", x, is_positive(x));
}
return 0;
} import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
# is_positive(x) is cmp w0, #0 then cset w0, gt. The C driver runs it on -3, 0 and 3.
# The comparison becomes a 0 or 1 value in w0 directly, with no branch in sight.
session.run("""
clang -O1 -o cset_a64 driver_cset.c cset_aarch64.s
./cset_a64
""") Output
$ clang -O1 -o cset_a64 driver_cset.c cset_aarch64.s $ ./cset_a64 is_positive(-3) = 0 is_positive(0) = 0 is_positive(3) = 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cset_session.py
Three values in, three Booleans out: -3 and 0 are not positive (strict gt), 3 is. The A64 manual lists
cset w0, gt as an alias of csinc w0, wzr, wzr, le, with le the inverse of gt: pick 0 when le holds, else
0 + 1. It also names cset as the preferred disassembly of that encoding.
Checkpoint
Check: csel on AArch64, and cset, csinc, csinv, csneg
RISC-V: Zicond, and the base-ISA mask when it is not there
RISC-V has no flags register and, until Zicond was ratified, had no standard conditional-move instruction. The
ratified Zicond extension (version 1.0.0 in the specifications library) adds two R-type instructions that read a
condition register instead of flags: czero.eqz rd, rs, rc writes rs into rd when rc is non-zero and 0
otherwise, and czero.nez rd, rs, rc does the opposite, writing rs into rd when rc is zero. The Zicond
chapter calls each one a conditional zero; two of them, joined by one more instruction, make a conditional select.
For max(a, b) the Zicond shape is slt to form the condition, two czero to zero out one half of the pick and
an or to combine:
# max_int(a, b) on RV64I with the Zicond extension (version 1.0.0 in the ratified library):
# slt computes the condition, then two czero instructions and an or make the select.
# czero.eqz rd, rs, rc writes rs into rd when rc is non-zero and zero otherwise;
# czero.nez rd, rs, rc does the opposite, writing rs when rc is zero.
.text
.globl max_int
max_int: # int max_int(int a, int b)
slt t0, a0, a1 # t0 = 1 if a < b, else 0
czero.nez t1, a0, t0 # t1 = a if a >= b (t0 == 0), else 0
czero.eqz t2, a1, t0 # t2 = b if a < b (t0 != 0), else 0
or a0, t1, t2 # a0 = max(a, b): one of the two is zero
ret The pattern is “one of t1 and t2 is forced to zero, so the or returns the other”: czero.nez t1, a0, t0 writes
a into t1 when t0 == 0 (that is, a >= b) and 0 otherwise, while czero.eqz t2, a1, t0 writes b when
t0 != 0. The Zicond chapter’s own select joins the two halves with add; or gives the same result, because one
of the halves is always 0. The two czero and the or after the compare are the whole decision, with no mask to
build.
RV64 chips built before the RVA23 profile made Zicond mandatory may not have it, so the base-ISA shape builds a mask
by hand. The same slt sets t0 to 0 or 1; subtracting it from the zero register wraps 1 into all-ones under two’s
complement (a 64-bit -1), and 0 stays 0. The mask then selects between a and b with one xor, one and and
one more xor:
# max_int(a, b) on RV64I without any extension: five instructions in the body.
# RISC-V System V: a is in a0, b in a1 and the return value goes back in a0.
# No conditional-move instruction here, so a mask built from slt picks between a and b.
.text
.globl max_int
max_int: # int max_int(int a, int b)
slt t0, a0, a1 # t0 = 1 if a < b, else 0
sub t0, zero, t0 # t0 = all-ones mask if a < b, else all-zeros
xor t1, a0, a1 # t1 = a XOR b
and t1, t1, t0 # t1 = (a XOR b) if a < b, else 0
xor a0, a0, t1 # a0 = a XOR t1 = b if a < b, else a
ret Five instructions in the body against four for the Zicond shape; both are branchless and both run both sides. When
you read them back in the last section, expect sub t0, zero, t0 to print as neg t0, t0: the disassembler shows
that encoding under its pseudo-instruction: A name an assembler accepts where an instruction goes although the instruction set has no instruction of that name: RISC-V li and la become one or more real instructions. The NASM manual also uses the word for its data lines, such as db, equ and times. More in “Assembler syntax: NASM, GNU as, Intel and AT&T” name. What decides between the two
shapes is the target, so answer the table’s two questions about yours:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Shape | Instructions in body | Hardware needed | Reads at a glance | Runs on every RV64 chip |
|---|---|---|---|---|
| Zicond select Core sequence: slt + czero.nez + czero.eqz + or | Good four: slt, two czero and an or, as recorded | Mixed needs the Zicond extension; the RVA23 profile mandates it, older chips may lack it | Good two czero names say which half is kept and when | Poor an older RV64 core without Zicond cannot execute it |
| Base-ISA mask Core sequence: slt + sub + xor + and + xor | Mixed five instructions, one more than Zicond on the same job | Good base RV64I only; no extension flag needed at the assembler | Mixed the sub-from-zero mask trick takes a moment to see | Good base RV64I only, so every RV64 chip runs it |
How the answers decide
- “Will the code run on every RV64 chip, or only on chips with the Zicond extension?” Everywhere, including older RV64 cores: rules out Zicond select. An older RV64 chip without Zicond cannot execute czero; the base-ISA mask uses RV64I only and runs on all of them.
- “Will the code run on every RV64 chip, or only on chips with the Zicond extension?” Only chips with Zicond (RVA23 and later) and “Does the function sit in a hot loop where every instruction matters?” Yes, every instruction is counted: rules out Base-ISA mask. Zicond saves one instruction in the body and reads as what it does; the mask gives up both when it is not needed.
- “Will the code run on every RV64 chip, or only on chips with the Zicond extension?” Only chips with Zicond (RVA23 and later) and “Does the function sit in a hot loop where every instruction matters?” No, one or two more is fine: adds a caveat to Base-ISA mask. The mask still works on a Zicond chip and is one instruction longer; the Zicond shape is still clearer.
- “Will the code run on every RV64 chip, or only on chips with the Zicond extension?” Everywhere, including older RV64 cores and “Does the function sit in a hot loop where every instruction matters?” Yes, every instruction is counted: adds a caveat to Base-ISA mask. The mask runs one more instruction per call than the Zicond shape; where Zicond is missing it is still the branchless choice, so measure it against the branch.
The short version: the mask when the binary must run on every RV64 core, the Zicond select when every core it targets is known to have the extension.
Checkpoint
Check: Zicond on RISC-V, and the base-ISA mask
When branchless helps and when it hurts
A cmov or a csel avoids one specific cost, a branch misprediction, and pays one specific cost: both sides of
the choice run on every call. The right shape depends on how predictable the branch is on the data the program
actually sees, as a short rule-of-thumb table makes clear:
| The branch is… | What the predictor does | Which shape wins |
|---|---|---|
| Nearly always one way (sorted input, feature flag off) | Guesses right nearly every time; branch cost near zero | A branchy jcc or b.cond, which skips the other side |
| 50/50 and random (shuffled scores, hashed keys) | Guesses wrong about half the time; each miss costs about 10 to 20 cycles | A branchless cmov or csel, which never mispredicts |
| In between (biased, but noisy) | Guesses with some wins, some misses | Both are close; measure |
The 10 to 20 cycles are what LLVM, the compiler framework clang is built on, assumes when it weighs this choice: its scheduling models charge a mispredicted branch 14 cycles on Intel’s Skylake and Alder Lake P cores, 13 on AMD’s Zen 4 and 10 on Arm’s Neoverse N2 and V2.
Branchless code also matters when a choice depends on a secret, such as a key byte or a password: a branch on
secret data makes the run time and the instructions fetched depend on that data, which constant-time code must
avoid, while a select between two registers runs the same instructions whichever value wins. A C if or ?: can
still compile to a branch, which is one more reason for the rule below.
The exact threshold between the two shapes on today’s cores is a measurement, not a formula; the performance module later in this track takes it up. The rule for now: measure on your own data, and read the disassembly to make sure the compiler kept the shape you asked for.
Finding the select in a disassembly
Look for the select by its name: cmovge on x86-64 carries its condition as a suffix, csel and cset on
AArch64 carry it as the last operand (gt), and RISC-V shows czero.nez or czero.eqz. A conditional branch
such as jge, b.gt or blt where you expected the select means the compiler chose a branch, and the argument
of this section no longer applies to that build.
Checkpoint
Check: When branchless code helps, and when it hurts
Side-by-side: max on three ISAs
The four sources above become four object files here, and each is read back as instructions. Apple clang assembles
the AArch64 source as a macOS object, which otool -tV lists; Homebrew’s LLVM cross-assembles the x86-64 source and
the two RISC-V sources as Linux ELF objects, which llvm-objdump lists:
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
# Cross-assemble each ISA's object file with Homebrew LLVM 23.1.2 and read the bytes back as instructions.
# Apple clang assembles AArch64 natively; the x86-64 and RISC-V objects are built as Linux ELF for the disassembly.
# -c stops after the assembler, so the object has one function and no addresses the linker would patch.
session.run("""
clang -c -o max_a64.o max_aarch64.s
otool -tV max_a64.o | cat
/opt/homebrew/opt/llvm/bin/clang --target=x86_64-linux-gnu -c -o max_x64.o max_x86_64.s
llvm-objdump -d --no-show-raw-insn --no-leading-addr max_x64.o
/opt/homebrew/opt/llvm/bin/clang --target=riscv64-linux-gnu -march=rv64gc -c -o max_rv.o max_riscv64.s
llvm-objdump -d --no-show-raw-insn --no-leading-addr max_rv.o
/opt/homebrew/opt/llvm/bin/clang --target=riscv64-linux-gnu -march=rv64gc_zicond -c -o max_rv_z.o max_riscv64_zicond.s
llvm-objdump -d --no-show-raw-insn --no-leading-addr max_rv_z.o
""") Output
$ clang -c -o max_a64.o max_aarch64.s
$ otool -tV max_a64.o | cat
max_a64.o:
(__TEXT,__text) section
_max_int:
0000000000000000 cmp w0, w1
0000000000000004 csel w0, w0, w1, gt
0000000000000008 ret
$ /opt/homebrew/opt/llvm/bin/clang --target=x86_64-linux-gnu -c -o max_x64.o max_x86_64.s
$ llvm-objdump -d --no-show-raw-insn --no-leading-addr max_x64.o
max_x64.o: file format elf64-x86-64
Disassembly of section .text:
<max_int>:
movl %esi, %eax
cmpl %esi, %edi
cmovgel %edi, %eax
retq
$ /opt/homebrew/opt/llvm/bin/clang --target=riscv64-linux-gnu -march=rv64gc -c -o max_rv.o max_riscv64.s
$ llvm-objdump -d --no-show-raw-insn --no-leading-addr max_rv.o
max_rv.o: file format elf64-littleriscv
Disassembly of section .text:
<max_int>:
slt t0, a0, a1
neg t0, t0
xor t1, a0, a1
and t1, t1, t0
xor a0, a0, t1
ret
$ /opt/homebrew/opt/llvm/bin/clang --target=riscv64-linux-gnu -march=rv64gc_zicond -c -o max_rv_z.o max_riscv64_zicond.s
$ llvm-objdump -d --no-show-raw-insn --no-leading-addr max_rv_z.o
max_rv_z.o: file format elf64-littleriscv
Disassembly of section .text:
<max_int>:
slt t0, a0, a1
czero.nez t1, a0, t0
czero.eqz t2, a1, t0
or a0, t1, t2
ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cross_max_session.py
Reading the four blocks top to bottom: the AArch64 body is cmp w0, w1, csel w0, w0, w1, gt, ret; the x86-64
body is movl %esi, %eax, cmpl %esi, %edi, cmovgel %edi, %eax, retq; the RISC-V base-ISA body is slt, neg
(the pseudo for sub t0, zero, t0), xor, and, xor, ret; the RISC-V Zicond body is slt, czero.nez,
czero.eqz, or, ret. Without the ret, the bodies are two instructions on AArch64, three on x86-64 and four
or five on RV64 depending on the extension, and every shape is branchless.
The x86-64 block looks reversed because llvm-objdump prints x86-64 in AT&T syntax, not the Intel syntax of the
source: the destination comes last, every register carries a % and the l suffix marks a 32-bit operation, so
cmovgel %edi, %eax is the source’s cmovge eax, edi. Step through the four bodies one at a time:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- AArch64 first. The cmp sets the flags and csel picks between its two source registers under the gt condition: w0 = (a > b) ? w0 : w1. Two instructions in the body, and the csel alone is the decision.
- x86-64: cmovcc instead. eax is loaded with b first, then cmp sets the flags, then cmovge copies a into eax when the signed condition holds. Three instructions in the body, because cmovcc names one source and otherwise keeps the old destination, so the fallback b needs its own mov.
- RISC-V with Zicond. RISC-V has no flags, so slt writes the condition into a register. Zicond adds two conditional-zero instructions that key off whether that register is zero: each keeps its value or writes 0, and an or joins the two. Four body instructions, no branch.
- RISC-V with the base ISA only. Without Zicond the recipe is a slt into t0, a negate for the all-ones mask, a xor of a and b, an and with the mask and a last xor to apply it: five instructions in the body.
- One idea, four counts. Every shape above holds the same if/else: pick a when a is the larger, otherwise pick b. Two, three, four and five instructions: the count follows what each ISA offers natively, and none of the four branches.
Two, three, four and five instructions for one max: the count follows what each instruction set offers, and none
of the four bodies needs a branch.
Checkpoint
Check: max side by side on three ISAs
Practise
Build the sources of this page yourself, on an Apple silicon Mac or with the tools from setting up the toolchain:
- Add a fifth pair,
(0, 0), todriver_max.c(the array size and the loop bound both say 4) and run the AArch64 build again. With equal inputsgtis false, so which register doescselcopy, and why does the printed line not tell you? - Rewrite
is_positiveon AArch64 so it usesgeinstead ofgt. Which inputs change their answer, and why? - Assemble
max_riscv64.swith-march=rv64gcandmax_riscv64_zicond.swith-march=rv64gc_zicond, as the recorded session does, and count the instructions in each listing. Then assemble the Zicond file with plain-march=rv64gcand read why the assembler refusesczero.
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A warm-up for freshers: how does cmov differ from jcc in what it costs the pipeline?
A jcc is a conditional branch: the processor guesses which way it goes, runs ahead on the guess and throws that
work away when the guess was wrong. A right guess costs almost nothing; a wrong one costs about 10 to 20 cycles on
current cores, because every instruction fetched after the branch is discarded. A cmov has nothing to guess: it reads the
flags the compare left and writes its destination from one of two sources, so the instruction stream never forks.
The price is that both sides of the choice are ready every call, while a jcc can skip the work of the side not
taken. On data the predictor can learn (sorted input, a flag that is rarely set) the branchy shape wins; on data it
cannot (shuffled, random, near half-and-half) cmov wins by the misprediction penalty it never pays.
Two harder questions continue from here: whether to swap a branchy max in a hot loop for csel or cmov, and
how to keep a password comparison constant-time on three instruction sets. Note the first thing you would measure
or check for each, then follow its link to the first step and follow-ups:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A colleague's inner loop runs max(a[i], b[i]) over two shuffled score feeds, 50 million pairs a second, and spends 18 % of its wall time in the branch itself. Would you move it to csel (AArch64) or cmov (x86), and what measurement convinces you?
-
Senior to staff
A password-equality routine must not branch on secret bytes. Where do cmov (x86-64), csel (AArch64) and Zicond (RISC-V) fit in it, what is the trap on each instruction set, and how do you verify that the compiler kept the shape?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does each ISA pick one of two registers under a condition without a branch?
cmov on x86-64 and csel on AArch64 test the flags a compare left. RISC-V, with the Zicond extension, joins two czero instructions with an or. No branch stands between the compare and the result.
-
How does a cmov differ from a conditional jump in what it costs the pipeline?
A conditional jump is guessed: a right guess costs almost nothing, a wrong one throws away the work done after it, about 10 to 20 cycles on current cores. A cmov has nothing to guess, but both inputs must be computed on every call.
-
When does branchless code win, and when does it lose?
It wins on data the predictor cannot learn, such as shuffled or near half-and-half input, because it never pays a misprediction. It loses on predictable data, such as sorted input or a rarely set flag, because it always pays for both sides.
-
What are the two RISC-V shapes of a branchless max, with and without Zicond?
With Zicond, four instructions: slt for the condition, two czero and an or. Without it, a five-instruction mask from the base ISA: slt, a subtract from zero, xor, and, then xor again.
-
How do you make sure the compiler really kept a branchless select?
Read the disassembly. The select shows under its own name, such as cmovge, csel or czero.nez; a conditional branch in its place means the compiler chose to branch.
Key takeaways
- A conditional select (
cmovon x86-64,cselon AArch64, two Zicondczerojoined by anoron RISC-V) picks one of two registers under a condition, with no branch between the compare and the result. setcc,csetandsltturn a comparison into a 0 or 1 value, which lets code treat a condition as a number without a branch.- Branchless code avoids the misprediction penalty but always pays for both sides of the choice every call; the shape wins on unpredictable data and loses on predictable data.
- RISC-V has two shapes: a four-instruction select with the Zicond extension (
slt, twoczeroand anor) and a five-instruction base-ISA mask (slt, sub-from-zero,xor,and,xor). - Reading the shape back from the disassembly is how to be sure the compiler kept it: the select appears under its
own name (
cmovge,csel,czero.nez), and a conditional branch in its place means the compiler chose a branch.
Terms in this lesson
- branch misprediction
- A wrong guess by the branch predictor about which way a conditional branch goes: the core throws away the work done on the guessed path and refetches from the correct target, about 10 to 20 cycles on current cores. Branchless code avoids the cost by removing the branch, at the price of running both sides.
- branchless code
- A pattern that computes a result without a conditional branch, using a conditional select, a conditional-zero or a mask built from a comparison, so no path is skipped and the branch predictor is never consulted. The pattern wins on unpredictable data and loses on predictable data because it always pays for both sides.
- conditional move
- An instruction that writes its destination from one of two sources (or a cheap change of one, such as +1 or NOT) under a condition, without a branch. x86-64 cmovcc and AArch64 csel test the flags; RISC-V has no flags, so Zicond's czero pair tests a register, and two of them with an or make the select.
References
- Intel 64 and IA-32 Architectures Software Developer Manuals (CMOVcc, SETcc, Jcc) (Intel Corporation)
- Arm A-profile A64 Instruction Set Architecture (CSEL, CSET, CSETM, CSINC, CSINV, CSNEG) (Arm Limited)
- RISC-V Unprivileged ISA: the Zicond extension for integer conditional operations (version 1.0.0) (RISC-V International)
- AMD64 Architecture Programmer's Manual, Volume 3 (CMOVcc) (Advanced Micro Devices)
- System V ABI, AMD64 Architecture Processor Supplement (micro-architecture levels: CMOV in the baseline) (x86-64 psABI project)
- RVA23 Profiles, version 1.0 (Zicond among the new mandatory RVA23U64 extensions) (RISC-V International)
- LLVM scheduling models: MispredictPenalty of the x86 (Skylake, Alder Lake P, Zen 4) and AArch64 (Neoverse N2, V2) models (LLVM Project)
- Using as: AT&T syntax versus Intel syntax (i386 dependent features) (GNU Binutils project)
- Guidelines for Mitigating Timing Side Channels Against Cryptographic Implementations (Intel Corporation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress