Keyboard Shortcuts

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

Sixteen Conditions, Four Bits, and the Inversion That Hides

24 minadvancedModule 1: The Surface: What the Instructions AreContinues from a64-immediate
24 min · AArch64: Encoding From The Ground Up

Why this matters

On x86-64, producing a boolean from a comparison takes a compare, a SETcc, and usually a widening move. On AArch64 it takes one instruction: csel w0, w1, w2, eq reads the flags subs wrote and writes one of two registers. There is no branch, no branch-delay slot, and no separate compare. That is the whole argument for the conditional-select group, and it is why a compiler that reaches for it turns a four-instruction sequence into one.

The reason this gets its own concept rather than a paragraph is that the encoding has a trap in it that produces correct-looking output when you get it wrong, and the trap is the kind that survives review: a decoder that models cset as “a csinc with both sources zeroed” reports every condition in the program inverted, and the result is 0 or 1 either way. Nothing about the output says you are wrong.

The condition is four bits, and here are all sixteen

Sixteen conditions in a four-bit field, and the table below is built by assembling one csel per value rather than by quoting a list of names:

$ ./a64dec.py --section=10 | sed -n '/10A\./,/Two names exist/p'
  value  bits    name    word        decoded here
  0      0000    eq      1a820020    csel     w0, w1, w2, eq
  1      0001    ne      1a821020    csel     w0, w1, w2, ne
  2      0010    cs      1a822020    csel     w0, w1, w2, cs
  3      0011    cc      1a823020    csel     w0, w1, w2, cc
  4      0100    mi      1a824020    csel     w0, w1, w2, mi
  5      0101    pl      1a825020    csel     w0, w1, w2, pl
  6      0110    vs      1a826020    csel     w0, w1, w2, vs
  7      0111    vc      1a827020    csel     w0, w1, w2, vc
  8      1000    hi      1a828020    csel     w0, w1, w2, hi
  9      1001    ls      1a829020    csel     w0, w1, w2, ls
  10     1010    ge      1a82a020    csel     w0, w1, w2, ge
  11     1011    lt      1a82b020    csel     w0, w1, w2, lt
  12     1100    gt      1a82c020    csel     w0, w1, w2, gt
  13     1101    le      1a82d020    csel     w0, w1, w2, le
  14     1110    al      1a82e020    csel     w0, w1, w2, al
  15     1111    nv      1a82f020    csel     w0, w1, w2, nv
                

One structural fact is visible in the fourth column and worth stating because it is the cheapest possible way to check the field position: the sixteen words are identical except for the nibble at bits[15:12]. Everything else — the destination at bits[4:0], the first source at bits[9:5], the second at bits[20:16], the whole opcode — is held constant across all sixteen rows. The condition is four bits in the middle of the word and that is the entire claim, and the harness asserts it as a bit pattern rather than as a sentence.

And there is a naming wrinkle that is a fact about the ISA and a nuisance for a cross-check. Values 2 and 3 have two names each. In a select the assembler calls them hs and lo; in a branch it calls them cs and cc. Both were measured: csel w0, w1, w2, cs assembles to 0x1a822020 and the disassembler calls it hs, while b.cs T assembles to 0x54000020 and the disassembler calls it cs. The same four bits, two names, and the printer picks by position. This page uses the select spelling throughout and concept 5 normalises the other one — and says that it does, which is the rule for every normalisation in that section.

The 4x4 table, and why op2 is not the operation

The conditional-select group has more than one field selecting the operation, and getting the relationship wrong is the first thing that goes. Here is the whole table, built as words and then asked about:

$ ./a64dec.py --section=10 | sed -n '/bit30 (invert)/,/SAME/p'
  bit30 (invert)  bits[11:10]  word        the oracle calls it       this decoder calls it
  0              00           1a800022    csel    w2, w1, w0, eq    csel    w2, w1, w0, eq
  0              01           1a800422    csinc   w2, w1, w0, eq    csinc   w2, w1, w0, eq
  0              10           1a800822    (blank)                   (undefined)
  0              11           1a800c22    (blank)                   (undefined)
  1              00           5a800022    csinv   w2, w1, w0, eq    csinv   w2, w1, w0, eq
  1              01           5a800422    csneg   w2, w1, w0, eq    csneg   w2, w1, w0, eq
  1              10           5a800822    (blank)                   (undefined)
  1              11           5a800c22    (blank)                   (undefined)
                

Two facts, and the first one corrects a very natural assumption.

There are four operations, not sixteen and not eight:csel, csinc, csinv and csneg, selected by the pair (bit 30, bits[11:10]) = (0,00), (0,01), (1,00) and (1,01). The first version of this decoder read bits[11:10] as the operation and got csinv and csneg wrong in the corpus — and those two disagreements were in the two instructions the branchless-csel discussion quotes, which is the worst possible place for a bug to land.

The other four cells of the 4x4 are UNDEFINED. The oracle prints <unknown> for all four and this decoder raises on all four. The first draft of the file's own comment called two of them “the SET forms”, which is a plausible-sounding wrong answer that would have taught a reader the group has six operations. (The second version of that table was wrong in a different instructive way: it assembled a line per cell, and since there is no mnemonic for (invert, op2) = (1, 0) the assembler silently produced a csinv for all four of the inv = 1 rows and the table printed csinv eight times. Building the cells as words is the only way a cell with no name can still be measured.)

And the SET forms are not new opcodes

Here is where the trap lives, and the table below is the evidence:

$ ./a64dec.py --section=10 | sed -n '/cset w0, eq  /,/cneg w0, w1, ge/p'
    cset w0, eq              1a9f17e0   csinc w0, wzr, wzr, ne   1a9f17e0   SAME
    cset w0, ne              1a9f07e0   csinc w0, wzr, wzr, eq   1a9f07e0   SAME
    csetm w0, eq             5a9f13e0   csinv w0, wzr, wzr, ne   5a9f13e0   SAME
    csetm x0, ne             da9f03e0   csinv x0, xzr, xzr, eq   da9f03e0   SAME
    cinc w0, w1, eq          1a811420   csinc w0, w1, w1, ne     1a811420   SAME
    cinc w1, w2, eq          1a821441   csinc w1, w2, w2, ne     1a821441   SAME
    cneg w0, w1, ge          5a81b420   csneg w0, w1, w1, lt     5a81b420   SAME
    csinc w0, wzr, wzr, al   1a9fe7e0   cset w0, eq              1a9f17e0   DIFFERENT
    csinc w0, wzr, wzr, ne   1a9f17e0   cset w0, eq              1a9f17e0   SAME
    csinc w0, wzr, wzr, nv   1a9ff7e0   cset w0, eq              1a9f17e0   DIFFERENT
    csinv w0, wzr, wzr, al   5a9fe3e0   csetm w0, eq             5a9f13e0   DIFFERENT
                

Read the first row and then the eighth. cset w0, eq is csinc w0, wzr, wzr, ne — the condition is inverted, and the two are different 32 bits that differ in the condition field and nowhere else. And the assembly text of csinc w0, wzr, wzr, al, which is the ninth row, is cset w0, ne: a different word again.

Why the inversion, in one sentence: “1 if the condition holds, else 0” is “0 + 0 if it does not hold, else 0 + 1”. Both sources are the zero register, so the sum is the constant, and the constant is 1 in the case where the condition is false. The assembler prints the user's condition and the encoding holds its negation, and both are right about what the instruction does.

So the shape of the bug is worth stating exactly, because it is the general form:

And note what a field map cannot show you. CSET reuses the same two field values as CSINC; what is different is that Rn and Rm are 31, and 31 means the zero register in this group. Four opcodes plus two register values give a sixth and a seventh instruction, and there is no bit anywhere that says “this is a set”. That is why the 4x4 table has no cset row and why the model in the artifact has to read the registers to name it.

NZCV, and what a user-mode program cannot do with it

Every condition is a function of four flag bits — Negative, Zero, Carry, Overflow. They are written by the S-suffixed instructions (adds, subs, ands, bics) and by nothing else a user-mode program can reach. There is no PUSHFLAGS, no LAHF, no mov rflags, rax.

The four bits are written by an operation and read by the next one, and the whole conditional group is one instruction wide from the write to the read. That is the structural reason csel is one instruction rather than two, and it is also the thing a user-mode program cannot inspect: you cannot read NZCV without consuming an operation's result, and there is no instruction whose sole effect is to put a value in those bits.

What cannot be measured here, and is not claimed anywhere in this course: the latency of the flag write, whether a particular core renames the flags, and whether a select is cheaper than a branch on a branch-free predictor. Those need an AArch64 machine and there is none on the build host. The artifact says so in section 15 in its own words rather than leaving it to a reader's discretion, and the landing page's limits block says it a second time.

The same four bits, five of them, in a branch

And a B.cond is bits[31:24] = 0b01010100, imm19 at bits[23:5], and the condition at bits[4:0]:

$ ./a64dec.py --section=10 | sed -n '/10C\./,/A B.cond is/p'
  value  bits    name    word        decoded here
  0      0000    eq      54ffffc0    b.eq     -8
  14     1110    al      54ffffce    b.al     -8
  15     1111    nv      54ffffcf    b.nv     -8
                

The same four bits are at position 4 here and at position 12 in a csel, and the two fields do not overlap. A decoder that finds one cond field and uses it for both gets every conditional instruction in the program wrong, in the same direction — and in this case the error is visible in the output, because b.eq and b.ne are different branches and a program that takes the wrong one misbehaves. That is the good case. The cset case above is the bad one, and it is the one that got its own concept's warning.

AArch64 does not have a single condition field. That is a cost of the encoding rather than an accident: two different places, two different widths, one meaning. Concept 2's field table shows both positions side by side, and this is what the two non-contiguous rows in it are about.

How often the compiler reaches for a select

$ ./a64dec.py --section=10 | sed -n '/conditional selects:/,/half of/p'
  conditional selects: 56   conditional branches: 49
  and the 45-looking number is not a typo: 23 of the 56 selects are
  the INVERTED forms -- cset, csetm, cinc, cinv, cneg -- which are
  the ones whose condition field is the negation of the one the
  mnemonic names.  A decoder that misses the inversion decodes 23
  instructions in this corpus with the condition backwards, and
  every one of them still produces a correct boolean for half of
  its inputs.
                

Both are real and both are used, and 23 of the 56 selects are the inverted forms this page is about. A decoder that missed the inversion would decode 23 of these with the condition backwards, and every one would still produce a correct boolean for half its inputs.

The family has nine names and the artifact's first version counted them with a startswith test — which misses csinc, csinv and csneg, because “csinc” starts with “csi” and not with “cse”. That printed 45 where the answer is 56, and the eleven missing instructions were three of the four operations in the group: the section's own count of its own family was short by exactly the operations whose selection depends on the bit-30/op2 pair. The list is now spelled out and the count printed as a sum of the numbers above it.

Which one a compiler reaches for is a decision about whether the two sides of the branch are equally likely — a property of the program, not of the architecture. So that number is a fact about this corpus and a fact about clang, and concept 5 measures what the choice costs in bytes rather than arguing about it.

Apply it

  1. Make the inversion bug and then hunt for it. In a64dec.py, in m_condselect, change cond_text(cond ^ 1) to cond_text(cond) in the cset branch. (Expect the section 12 cross-check to report 14 disagreements — the 14 csets in the corpus — and expect the section 10 table to still print 16 rows of which 8 are now WRONG while looking exactly as it did before. That is the shape of the bug: a table of plausible names, a cross-check that finds it, and a reader who reads the table first and believes it.)
  2. Prove the field is where the table says it is. Take the sixteen csel words from the first table and check that the only bits that differ are bits[15:12]. (Expect the words to be 0x1a8_?_020 with the ? running 0 through f in order — sixteen words, one varying nibble, everything else identical. Then do the same for the sixteen b.cond words and expect the varying nibble to be in a different place entirely, at the bottom of the word. Two groups, one meaning, two positions.)
  3. Find the reserved cells yourself. Build 0x1a800822 and 0x1a800c22 — the two op2 values the table above calls UNDEFINED — and ask the assembler to assemble csel w0, w1, w2, eq variants that would land there, or hand them to the decoder. (Expect <unknown> from the disassembler and an explicit UNDEFINED from this decoder, rather than an alias onto csinc or csneg. A decoder that lets a reserved value alias onto a real instruction is worse than one that admits it does not know, because the admission is visible and the alias is not.)

Connect

Backwards, x86-flags is where the four-instruction-to-boolean sequence comes from, and concept 1 is where cset appeared as the last row of the alias table with a one-line warning. Concept 2 measured the condition field and the two op2 rows that are not contiguous.

Forwards, concept 5 is the cross-check, and this page is the reason it exists: a bug that inverts a condition produces output that is 0 or 1 either way, and the only instrument that finds it is a second reader that was not written by the same hand. Back to the field map if the op2 rows there stopped making sense.

Outward, and this is the concept a compiler backend is most likely to get wrong. Every code generator that emits CSET has to know that the field holds the inverted condition, because a backend that inverts it twice produces a correct program on the inputs where both tests agree and an inverted one everywhere else — and a boolean inverted is a boolean, so the failure rate looks like a flaky test rather than an encoding bug.

render_lesson_js(&mut page)