Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

No Flags Register, No Condition Codes, No CMOV

27 minadvancedModule 2: The Absence: What Is Missing, and What It BuysContinues from rv-calling
27 min · The RISC-V ABI, and the Register That Isn’t There

Why an absence is worth twenty-seven minutes

Here is the spine, in the course mission’s own words, and every page of this section is downstream of it:

  RISC-V has no flags register and no condition codes.  x86-64 has
  EFLAGS, AArch64 has NZCV, RISC-V has NOTHING -- so a branch is the
  only conditional thing, and that single absence explains why
  AArch64 needs csel/cset at all and why a compiler emits a branch on
  one target and a cmov on another.
                

An absence is the hardest kind of claim to teach, because there is nothing to point at. A reader who has never compared the three targets has no reason to believe that CMOVcc is not merely unusual on RISC-V but absent — and the difference between those two is the difference between “clang chose a branch here” and “clang had no choice.”

And it is fully measurable without hardware, which is why this course exists on a host with no RISC-V silicon. The compiler’s choice is a static property of its output, and the output is bytes. There is no RISC-V machine here, no emulator, no RISC-V linker, and no timing anywhere in this course — but none of those are needed to establish that a mnemonic does not appear in an object file, or that one does.

What is not needed here and is worth saying plainly: this page cannot tell you which of the three targets is faster. Not the branch, not the cmov, not the mask. The x86-64 ABI course in the first section measured a 4.92x and a 27.65x on hardware it could run; this course has no counterpart for either number and does not invent one. Read the counts below as counts.

The manual admits it, and the admission is the whole argument

A course that inferred the absence from a compiler’s output would be arguing from a hole, and a hole is weak evidence. The manual says it outright, and saying so is what turns a negative into a measurement of a documented decision. QUOTED, rv32-unprivileged, the branch-design note:

  "We considered but did not include conditional moves or predicated
   instructions, which can effectively replace unpredictable short
   forward branches."
                

Read what that concedes. It is not a claim that a conditional move would be slower. It is a claim that a conditional move can replace an unpredictable short forward branch, and that the architecture declined to let it. So the absence is a choice, and this course is measuring a choice rather than inferring one from a gap in a table.

The same note says the design went the other way deliberately, in a sentence that names the alternatives:

  "The conditional branches were designed to include arithmetic
   comparison operations between two registers ... rather than use
   condition codes (x86, ARM, SPARC, PowerPC)."
                

That is the reason blt exists at all. On x86-64 a comparison is an instruction that writes a register you do not name (cmp sets EFLAGS), and a branch is a separate instruction that reads it (jl). On RISC-V the comparison IS the branch — blt a0, a1, target compares two registers and transfers control, and there is no separate instruction and no state in between.

THE THREE ANSWERS, and there are exactly three

With no CMOVcc and no SETcc to reach for, a compiler implementing x < 0 ? a : b has three moves and no fourth:

Eleven functions of ?:, four levels, three buckets

The corpus is noflags.c: eleven functions, every one of them a ?: on an integer condition, compiled at four optimisation levels and classified on the mnemonic by the decoder, out of the object file. MEASURED.

$ python3 rvabi.py --run | sed -n '/level  branch/,+5p'
  level  branch  mask  other  calls  what is called
  -O0    79      47    245    0      (none)
  -O1    32      34    9      0      (none)
  -O2    32      33    9      0      (none)
  -Os    30      31    9      0      (none)
                

Read the branch column and the mask column together at -O2 and the shape is the finding: the compiler picks a BRANCH for most of the corpus and reaches for ARITHMETIC only where arithmetic is cheaper than a branch — myabs, mask, sign, absu, which are all the same three-instruction idiom, plus sign, which is one instruction. And the other column falls from 245 to 9 as the optimiser stops emitting the bookkeeping the audit is counting, which is the reader’s warning that a count over -O0 is a count of a compiler’s scaffolding.

THE CONSTRUCTIVE HALF, which is the half nobody states

Here is the row the course title is about, instruction by instruction at -O2, decoded from the bytes. MEASURED-ON-BYTES:

$ python3 rvabi.py --run | sed -n '/THE CONSTRUCTIVE ROW/,+22p'
  function  bytes  instruction
  myabs     4      sraiw      a1, a0, 31
  myabs     2      c.xor        a0, a1
  myabs     2      c.subw       a0, a1
  myabs     2      c.jr         ra
  mymin     4      blt        a0, a1, 0x10
  mymin     2      c.mv         a0, a1
  mymin     2      c.jr         ra
  mysel     2      c.beqz       a0, 0x36
  mysel     2      c.mv         a2, a1
  mysel     2      c.mv         a0, a2
  mysel     2      c.jr         ra
  mask      4      sraiw      a1, a0, 31
  mask      2      c.xor        a0, a1
  mask      2      c.subw       a0, a1
  mask      2      c.jr         ra
  sign      4      srliw      a0, a0, 31
  sign      2      c.jr         ra
  absu      4      srliw      a1, a0, 31
  absu      2      c.xor        a0, a1
  absu      2      c.subw       a0, a1
  absu      2      c.jr         ra
                

sign is ONE instruction: srliw a0, a0, 0x1f, and that is the whole of (x < 0) ? 1 : 0 for a signed int. There is no SETcc to reach for and there is nothing to reach for one WITH — the comparison never left a register, it went straight into the shift’s operand. The result is a program in which the condition is a VALUE rather than a STATE, and that distinction is the whole of the difference between the three architectures.

So the absence of a flags register is not only a cost. It is what makes min, max, clamp and abs expressible without a conditional move at all. The RISC-V answer is that sltuWRITES A REGISTER, so the comparison’s result is data and the selection is arithmetic on data. On x86-64 the comparison’s result is bits in a register you do not name, so the selection has to be an instruction that reads those bits — and that instruction is CMOVcc.

Read myabs against the same C on x86-64: mov / neg / cmovs. Also three instructions, but one of the three reads flags, and the instruction that reads flags is one an architecture has to define, specify, and give a mnemonic. RISC-V’s three are sraiw/xor/sub, and none of them is conditional.

And mymin is a BRANCH: blt a0, a1, .LBB1_2, then mv, then ret. That is where a compiler with CMOVcc would have written a conditional move and this one cannot — and the interesting fact is that it does not want to. mymin is three instructions and cmov would have made it four.

Three targets, one C function, three DISJOINT sets

Now the comparison that the whole section exists for. One source file, three --target= flags, one compiler, one optimisation level — so a row that disagrees with another row is a difference between the targets and not between the bodies of work someone typed. IT IS NOT A TIMING AND NOT A SPEEDUP, and there is no ratio anywhere in this table.

$ python3 rvabi.py --run | sed -n '/the mnemonics in the corpus/,+3p'
  target  the mnemonics in the corpus            the idiom they are        total
  riscv64  blt(6), bge(4), c.beqz(5), c.bnez(1), bne(1)   branches          17
  aarch64  csel(14), cneg(2)                     conditional moves / sets 16
  x86-64   cmovel(3), cmoveq(3), cmovgel(1),
           cmovgl(3), cmovll(4), cmovsl(2)        conditional moves        16

  THE THREE SETS, DISJOINT -- computed, and printed whatever they
  turn out to be:
    riscv64  n x86-64   = EMPTY
    aarch64  n riscv64  = EMPTY
    aarch64  n x86-64   = EMPTY
    union of the three = 13 distinct mnemonics; sum of the sets = 13
    and every set is non-empty: aarch64=2, riscv64=5, x86-64=6
                

Union is 13, sum of the three sets is 13, so nothing is in two sets. The disjointness is computed, and printed whether or not it is empty, because the first version of this section wrote the word “disjoint” in a paragraph and never computed an intersection — which is the difference between a claim and a measurement, and the same failure shape as the zero-disagreement cross-check.

And note the shape of each set. RISC-V’s five are all branches. AArch64’s two are csel and cneg. x86-64’s are six cmov spellings — six rather than one because cmovll is the 32-bit “less” form and cmovll is not cmovl, and there are sixteen conditions times two operand sizes. A reader who wants to see that table is better served by a prefix match than by sixteen enumerated spellings, and the artifact matches on the prefix for a reason it documents: an enumerated table goes quietly empty the first time one is missed, and an empty table reads as “this target has no conditional move”, which is the one conclusion it must never support.

The three idioms, instruction by instruction

$ python3 rvabi.py --run | sed -n '/THE THREE IDIOMS SIDE BY SIDE/,+4p'
    mymin    a < b ? a : b
      riscv64 : blt        a0, a1, 0x10 | c.mv         a0, a1 | c.jr         ra
      aarch64 : cmp   w0, w1    | csel  w0, w0, w1, lt | ret
      x86-64  : movl  %esi, %eax | cmpl  %esi, %edi | cmovll %edi, %eax | retq

        the idiom: riscv64 branch | aarch64 csel | x86-64 cmov
                

Three architectures, one C function, one compiler — and the arithmetic is not a cost either. On x86-64 the sequence for mymin is mov / cmp / cmov / ret: four instructions where RISC-V’s is three. And the reason is not that x86-64 is worse at selects:

So the absence of a flags register is not uniformly a cost. It costs the compiler a CMOVcc to reach for, and it saves the mov and the cmp that CMOVcc depends on.WHICH OF THOSE WINS ON REAL HARDWARE IS NOT MEASURABLE HERE AND IS NOT CLAIMED — there is no RISC-V machine, no AArch64 machine, and no cycle measured anywhere in this course.

Why csel has a fourth operand at all

“csel exists because NZCV exists” is only worth something if csel really is an instruction that reads a condition, and the condition is a four-bit field. The artifact decodes the AArch64 words with the AArch64 course’s own a64dec.py — a second decoder, from a different course, and neither it nor rvdec.py is a disassembler:

$ python3 rvabi.py --run | sed -n '/word.*the reader says/,+3p'
  word        the reader says  the decoder says         condition field
  0x5a805400  cneg             cneg     w0, w0, mi   bits[15:12] = 0x5   cond 5
  0x1a81b000  csel             csel     w0, w0, w1, lt bits[15:12] = 0xb  cond 11
                

And here is the out-of-encodings argument, counted rather than asserted. A conditional move needs three registers — two candidates and a destination — and the I-type format has three register fields: rd at bits[9:7], rn at bits[19:15], rm at bits[4:0]. A 64-bit I-type instruction is 32 bits: 7 of opcode, 3 of funct3, 15 of register, and 7 of immediate. The condition needs 4 bits and the immediate field has 7, so THERE IS NO ROOM LEFT for a condition in a word that already has three registers in it. csel is therefore a DIFFERENT ENCODING, not an instruction with an extra field — which is the strongest form the argument can take, because it is arithmetic rather than a design preference.

One parsing note, because it is the kind of failure that prints like a result. The first version of that table matched the disassembly with a regex over the whole line, and its \s+ after the instruction word consumed the tab along with the padding — so the “mnemonic” it captured was the first operand. Nothing matched, the table came out empty, and the section that exists to prove that csel is an instruction rather than a name reported no instruction and no error. The section now says how many words it found, and it splits the line on its tab.

Try it

  1. Prove the absence by trying to write it.(Write c.mv a0, a1 and then write cc.mv a0, a1, or setl a0 if you want a diagnostic. Assemble both for riscv64. The first succeeds; the second is not an instruction on this architecture, and an assembler that rejects a mnemonic is the cheapest possible proof that a mnemonic is absent rather than merely unused. Then count the ?: in the surrounding code that could have used it.)
  2. Count branches, and read the number as register pressure rather than style.(bigsel takes fifteen arguments and makes five selects. At -O2 the compiler emits FIVE BRANCHES and spills to the stack to reach the arguments past a7. There is no masking alternative that fits in the registers it has, so the branch is not a preference — it is the only encoding left. Now write your own function with fifteen arguments and one select, and see how many branches it gets. The count of branches in a function is a measure of how much register pressure the machine has, and that is a better use of the number than a style guide is.)
  3. Find the control row, and then break it.(twice uses the SAME condition twice and collapses to a single add on all three targets — the compiler noticed the expression is commutative in the two selects and the condition vanished. Then perturb it so the two selects are not interchangeable and watch the branches come back. A corpus where every row came out the same way would be a corpus measuring nothing; a control you can break is what makes the rest believable.)

Connect

Backwards, concept 1 established the register set this page uses as operands: a0–a7 and fa0–fa7 are where values arrive, so the argument registers are what a ?: operates on. rv-immediate is where blt’s displacement lives — and why it is a B format with a non-contiguous immediate, which is a cost of the same decision in the encoding.

Across architectures, a64-cond is this page from the other side. There the condition codes exist, and the interesting question is what you can do with them — which is a question about the breadth of the conditional vocabulary. Here the interesting question is what a compiler does with nothing, and the answer turns out to include the single most useful instruction in the corpus. Same subject, opposite direction: on AArch64 the flags are the resource, on RISC-V the missing resource is what shapes the code.

And x86-map is where cmovll’s actual bit layout lives, because this page counts the six spellings without explaining why there are six. The size suffix and the condition suffix are two separate things in the ModRM byte, and the reason cmovll is not cmovl is a fact about the encoding rather than about spelling. x86-flags is the other half: it is where EFLAGS is treated as the register RISC-V does not have.

Forwards, concept 3 turns the absence into a register-count claim: a callee here saves fewer registers than an x86-64 callee for the same C, because there is no flags register to save — and that is countable in the corpus.