Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 on x86-64: mov, cmp, cmovge S · max_x86_64.s
# 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

  1. Question 1 of 2 mov eax, esi; cmp edi, esi; cmovge eax, edi is max_int(a, b). Which of these is true of this three-instruction body on x86-64?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It always executes every instruction, with no misprediction and no branch recovery

    cmovcc moves a source register into a destination under a condition code, without a branch. Its body always runs both sides of the choice: the mov loads b into eax, the cmp sets the flags and the cmovge overwrites with a when the condition holds. No path is skipped, so the predictor plays no part.

  2. Question 2 of 2 A function runs cmp edi, esi, then mov eax, esi, then cmovge eax, edi. Why is the mov safe between the cmp and the cmovge?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: mov writes no flag, so the flags stay as cmp left them

    The flags hold the result of the last instruction that wrote them. mov writes a register and no flag, so the cmp's answer still sits in EFLAGS when the cmovge reads it: the three-instruction body is correct.

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 on AArch64: cmp then csel S · max_aarch64.s
// 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
driver_max.c: calls max_int on four pairs C · driver_max.c
// 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;
}
max_int on four pairs of signed integers Python · max_session.py
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 on AArch64: cmp with 0, cset gt S · cset_aarch64.s
// 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
driver_cset.c: calls is_positive on three values C · driver_cset.c
// 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;
}
is_positive turned into a 0 or 1 value, no branch Python · cset_session.py
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

  1. Question 1 of 2 The AArch64 run printed max(-2147483648, 2147483647) = 2147483647. Which two instructions make the pick?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: cmp w0, w1 then csel w0, w0, w1, gt

    max_int(a, b) in the lesson is cmp w0, w1 then csel w0, w0, w1, gt and ret. The csel is the whole decision; no branch sits between the compare and the result. INT_MIN against INT_MAX picked INT_MAX, as the run shows.

  2. Question 2 of 2 cmp w0, #0 then cset w0, gt is is_positive(x). The run printed is_positive(0) = 0. Why 0 and not 1?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The gt condition is strict: Z = 1 or N differs from V makes it false, and 0 == 0 sets Z = 1

    cset turns a condition code into a 0 or 1 value. For is_positive the condition is gt, strict signed greater than, so 0 is not positive and the function returns 0; 3 passes and returns 1. The widening to a whole register is automatic (w0 writes the low 32 bits of x0 and clears the top 32).

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 on RV64 with Zicond: slt, czero.nez, czero.eqz, or S · max_riscv64_zicond.s
# 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 on RV64I: slt, mask, xor, and, xor S · max_riscv64.s
# 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:

Interactive · answer and compare Zicond or the base-ISA mask on RV64? Answer two questions about the target and the table marks what still fits and says why.
Will the code run on every RV64 chip, or only on chips with the Zicond extension?
Does the function sit in a hot loop where every instruction matters?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Two branchless selects on RV64 against four deciding points, with the body counts the recorded disassembly showed
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

  1. Question 1 of 2 The Zicond max body is slt t0, a0, a1; czero.nez t1, a0, t0; czero.eqz t2, a1, t0; or a0, t1, t2. Why are the two czero in opposite senses?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One keeps a when the condition is false, the other keeps b when it is true, so the or returns the value that was not forced to zero

    The branchless select is "one of the two halves is forced to zero, so an or returns the other". czero.nez zeros its destination when the condition register is non-zero, czero.eqz when it is zero: together they turn a condition into a two-way choice without a branch.

  2. Question 2 of 2 The base-ISA max body is slt t0, a0, a1; sub t0, zero, t0; xor t1, a0, a1; and t1, t1, t0; xor a0, a0, t1. What does sub t0, zero, t0 compute?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An all-ones mask when a < b, all-zeros otherwise

    The move here is turning a 0 or 1 into a bitwise mask. Subtracting 1 from 0 under two's complement wraps to all-ones; subtracting 0 from 0 gives 0. The mask then selects between a and b with xor and and in two more instructions.

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

  1. Question 1 of 2 A tight loop runs max_int(a[i], best) on an input where a[i] is above best about half the time, in no pattern. Which shape usually wins on a modern core?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The branchless csel or cmov, because the branchy jcc mispredicts about half the time

    Branchless code avoids the misprediction penalty but always pays for both sides. On unpredictable data the penalty dominates, so csel and cmov win; on predictable data the branchy shape can be as fast or faster, because the predictor rarely misses and the skipped work costs nothing.

  2. Question 2 of 2 The same tight loop runs on an input that is almost sorted, so a[i] > best fires only every few hundred items. What is likely to be fastest?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The branchy shape, because the predictor is right nearly every time

    Branchy wins when the branch is predictable; branchless wins when it is not. The rule for now: measure.

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:

Disassembly of max_int on AArch64, x86-64 and RV64 Python · cross_max_session.py
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:

Interactive · step by step One if/else across three ISAs, no branch Step through the four shapes and watch the body grow from two instructions to five, by what each ISA offers.
One if/else across three ISAs, no branch, step 5 of 5. Showing AArch64 (cmp + csel: two), x86-64 (mov + cmp + cmovge: three), RISC-V (Zicond) (slt + two czero + or: four), RISC-V (base) (slt + mask + xor + and + xor). In focus: AArch64, x86-64, RISC-V (Zicond), RISC-V (base).AArch64cmp + csel: twox86-64mov + cmp + cmovge: threeRISC-V (Zicond)slt + two czero + or: fourRISC-V (base)slt + mask + xor + and + xor

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 The recorded listing of the AArch64 max_int shows cmp w0, w1, csel w0, w0, w1, gt and ret. Counted the way this lesson counts a body (without the return), how many instructions make the decision?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Two, with ret as the return

    The AArch64 shape is as compact as the lesson claims: cmp + csel + ret. The recorded otool listing shows the three lines at offsets 0, 4 and 8, because every A64 instruction is 4 bytes long.

  2. Question 2 of 2 The RISC-V base-ISA disassembly printed neg t0, t0 for sub t0, zero, t0. Why does llvm-objdump pick the pseudo?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: neg is a RISC-V pseudo-instruction that encodes exactly as sub rd, zero, rs

    Assemblers accept many RISC-V pseudo-instructions, such as li, la, mv, neg and bgt. When one stands for exactly one real instruction, as neg does for a subtraction from zero and mv for an addi of 0, llvm-objdump prints the pseudo name back by default. Pseudos that expand to two instructions, or that only swap operands (bgt becomes blt), read back as the real instructions.

Practise

Build the sources of this page yourself, on an Apple silicon Mac or with the tools from setting up the toolchain:

  1. Add a fifth pair, (0, 0), to driver_max.c (the array size and the loop bound both say 4) and run the AArch64 build again. With equal inputs gt is false, so which register does csel copy, and why does the printed line not tell you?
  2. Rewrite is_positive on AArch64 so it uses ge instead of gt. Which inputs change their answer, and why?
  3. Assemble max_riscv64.s with -march=rv64gc and max_riscv64_zicond.s with -march=rv64gc_zicond, as the recorded session does, and count the instructions in each listing. Then assemble the Zicond file with plain -march=rv64gc and read why the assembler refuses czero.

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.

  1. Question 1 of 11 Which x86-64 processors have cmovcc, the conditional-move instruction?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: Every x86-64 processor: CMOV is part of the x86-64 baseline

    Intel's SDM entry for CMOVcc says the instructions were introduced in the P6 family, and the x86-64 psABI puts CMOV in the baseline. Code built for x86-64 can emit cmovge without a feature check, which is why the compiler reaches for it so freely.

  2. Question 2 of 11 A loop runs cmov 100 million times on data where the condition is true about half the time. What does the branchless shape avoid, compared with a jcc shape, and what does it still pay for?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: Avoids branch misprediction; still pays for both sides of the choice every call

    Branchless code always runs both sides of the choice. The win is that the processor is never asked to guess the branch; the cost is the work it does to find both answers. On random data the win pays, on predictable data the cost does.

  3. Question 3 of 11 Which of these are members of the AArch64 conditional-select family in A64 (DDI 0602)?

    Choose every answer that is right.

    Show the answer: Question 3 of 11

    Answer:

    • csinc
    • csneg
    • csel

    The conditional-select family of A64 is csel, csinc, csinv and csneg: pick a source register, or the second source after a cheap transformation (plus 1, bitwise NOT or two's complement). cset and csetm are aliases of csinc and csinv with the zero register.

  4. Question 4 of 11 Arm's A64 instruction set documentation lists cset w0, cond as an alias of csinc w0, wzr, wzr, invert(cond). Why does this give 0 or 1?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: wzr always reads as 0; csinc returns 0 when the inverted condition holds, and 0 + 1 = 1 when it fails

    cset is a readable name for the same encoding as csinc with wzr. csetm is similar but uses csinv (NOT 0 = all-ones) and writes an all-ones mask instead of a 1. Both let a comparison become a value with no branch.

  5. Question 5 of 11 In the Zicond max body slt t0, a0, a1; czero.nez t1, a0, t0; czero.eqz t2, a1, t0; or a0, t1, t2, which instructions read the condition that slt left in t0?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: The two czero instructions, which both name t0 as their condition register

    slt leaves 0 or 1 in t0, and both czero name t0 as their condition: czero.nez keeps a when t0 is 0, czero.eqz keeps b when t0 is not 0. The base-ISA shape instead turns t0 into a mask with sub-from-zero and applies it with xor and and; the or of the Zicond shape never reads t0 at all.

  6. Question 6 of 11 A RISC-V binary must run on every RV64 chip, including older cores built before Zicond existed. Which branchless-max shape fits?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: The base-ISA mask with slt, sub, xor, and and xor

    Portability on RISC-V is a question of which extensions the target chip has. The base ISA is the common ground; Zicond is an extension, mandatory from the RVA23 profile on and possibly missing on cores built to earlier profiles.

  7. Question 7 of 11 A max(a, b) loop is slow in production. A profile shows about 55 % of calls take the a > b path, in no pattern anyone can see. What do you do?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: Try a branchless select and measure both shapes on the real inputs: a near-even split with no pattern is close to the worst case for the predictor

    This is the rule the lesson states: a branch the predictor cannot learn favours the branchless shape, and a measurement on the program's own data settles it. A 55/45 split with no pattern is unpredictable in effect.

  8. Question 8 of 11 What does "branchless code always pays for both sides" mean in practice?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: The processor evaluates both candidates every call, even the one the result throws away

    Both operands of the pick are computed. For max(a, b) that is only a cheap read; for a select whose two sides each do real work, "both sides" can be a lot of wasted cycles. The misprediction save has to beat that waste.

  9. Question 9 of 11 In the recording, the base-ISA max_int runs five instructions before ret and the Zicond version four. What does the Zicond pair replace?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: The mask made by sub and the xor, and, xor merge: each czero keeps one value or zero, and or combines them

    slt is shared. The base ISA turns its 0 or 1 into a mask by subtracting it from zero and merges with xor, and and xor; Zicond zeroes the unwanted value with czero.nez or czero.eqz and ORs the two results, one instruction shorter.

  10. Question 10 of 11 A password check must not branch on secret data. Which of the following is a correct reason to prefer csel on AArch64 or cmov on x86-64 over a conditional branch?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: A branch on secret data makes the run time and the instructions fetched depend on that data; a register-to-register select runs the same instructions whichever value wins

    Constant-time code is a defensive-security idiom: the shape of the function must not depend on secret inputs. Branchless selects remove the branch; reading the disassembly confirms the compiler kept them that way.

  11. Question 11 of 11 llvm-objdump printed the x86-64 max_int in AT&T syntax. In cmovgel %edi, %eax, which register is the destination?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: eax: AT&T syntax puts the destination last

    llvm-objdump prints x86-64 in AT&T syntax: source first, destination last, a % before every register and a size suffix on the mnemonic (l for 32 bits). The source file used Intel syntax, so the same instruction reads cmovge eax, edi there, with eax first.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 (cmov on x86-64, csel on AArch64, two Zicond czero joined by an or on RISC-V) picks one of two registers under a condition, with no branch between the compare and the result.
  • setcc, cset and slt turn 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, two czero and an or) 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.