Keyboard Shortcuts

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

A Set of Documents, Not a List

24 minadvancedModule 1: The Documents: What the Instructions Are
24 min · RISC-V: The Encoding Spectrum

Why this matters

Every architecture in this collection has an instruction set, and you have learned two of them as lists: x86-64 has a list, and AArch64 has a list. RISC-V is the odd one out, and the oddness is a design decision with a measurable consequence. The base is 40 instructions and everything else is a letter.

That sentence is usually delivered as a slogan about “modularity” and it is worth exactly nothing until you ask what it does to a compiled object. So this concept asks: compile the same eleven functions of ordinary C at eight different -march settings, and diff what appears and disappears. The answer is not “M adds multiply” and it is not monotonic in “more features”, and two of the rows are the sharpest things in the course.

Why it has to come first, before any of the encoding: because a decoder is written against a set of documents, not against an architecture. Every guard in the artifact’s dispatch table belongs to one letter. When you write a decoder you are answering “does this word belong to the M extension or to the base?” and the answer is a value in a funct7 field, which is concept 2’s subject. The letters are not a packaging story; they are a dispatch story.

Forty, and the string in the object file

The base count is QUOTED, from the unprivileged manual, section 2.1, and the manual is unusually honest about it:

“RV32I contains 40 unique instructions, though a simple implementation might
 cover the ecall/ebreak instructions with a single SYSTEM hardware
 instruction that always traps and might be able to implement the fence
 instruction as a nop, reducing base instruction count to 38 total.”
                

Forty. Compare that with the corpus’s own opcode space, which concept 2 counts: ordinary C touches 19 of the 128 possible seven-bit opcodes. The base is small on purpose and the rest of the space is the letters.

But the interesting string is not the -march you typed. It is the one the object file carries, and the object is what a linker reads. Tag_RISCV_arch is read out of .riscv.attributes by this file’s own ELF reader, and the rows below are MEASURED-ON-BYTES:

$ python3 rvdec.py --run | sed -n '/-march=rv64imafdc$/,/^$/p'
    rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zicsr2p0_zmmul1p0_zaamo1p0_
      |  zalrsc1p0_zca1p0_zcd1p0
      names: rv64i m a f d c zicsr zmmul zaamo zalrsc zca zcd
                

Six letters in, twelve names out. And the four the user did not type are not toolchain decoration:

So “the ISA is a set of independent documents” is not a slogan here. It is a string in the object file, and the string is longer than the -march that produced it.

The sweep, and what each letter actually bought

The same corpus.c — arithmetic, masks, conditionals, a loop, a struct walk, a multiply, a divide, a float accumulator, a call — compiled eight ways. MEASURED, and a compiler version is in the numbers, so read the columns and not the third digit:

$ python3 rvdec.py --run | sed -n '/-march       insns/,/^$/p'
  -march       insns   bytes    2-byte   B/insn    distinct mnem
  rv64i        146     584      0        4.000     28
  rv64im       114     456      0        4.000     27
  rv64if       146     584      0        4.000     28
  rv64imf      114     456      0        4.000     27
  rv64imafd    95      380      0        4.000     29
  rv64imafdc   95      246      67       2.589     29
  rv64gc       95      246      67       2.589     29
  rv64gcv      178     524      94       2.944     49
                

The mnemonic count barely moves — 27 or 28 for every non-vector setting — and the instruction count falls and rises with the letters in a way that is not monotone in “more features”. Read the rows as pairs: what did this letter add, and what did it make unnecessary?

One: M is not only additive

    rv64i      -> rv64im      insns  146 ->  114   bytes   584 ->   456
        GAINED  3:  divuw(1)  mul(2)  rem(1)
        LOST    4:  j(1)  jr(1)  sext.w(2)  srli(2)
                

An extension that adds three instructions and removes four. The base has no multiply, so the compiler synthesises one out of shifts and adds, and a shift-based multiply is a sequence, and sequences branch. Adding M removed branches: j, jr, sext.w and srli all disappear, and the instruction count falls by 32.

“An extension adds instructions” is true of the ISA and false of the object, and the object is what a linker sees. This is the first of the three retractions on this page, and it is worth a moment: the same claim in the AArch64 section would have been true, because AArch64’s base has mul and its extensions add capabilities the base genuinely lacks. The claim is not architecture-neutral, and a course that states it as if it were would be teaching a fact about one ISA and calling it a principle.

Two: F buys this corpus nothing at all

  rv64if       146     584      0        4.000     28
  rv64i        146     584      0        4.000     28
                

Identical instruction count, identical byte count. The reason is in the corpus: its only floating-point function accumulates a double, and a double needs D. So the F extension buys this corpus absolutely nothing.

This is a correct measurement and it is a warning about the method, which is why the artifact keeps it rather than dropping the row. An -march sweep over a corpus that does not use a feature reports the feature as worthless. A sweep is a measurement of a corpus’s use of the extensions, and the corpus has to be in hand when you read it. The next row is the one that shows F is not worthless:

Three: D replaces calls with stores

    rv64imf    -> rv64imafd   insns  114 ->   95   bytes   456 ->   380
        GAINED  4:  fcvt.l.d(1)  fld(1)  fmadd.d(1)  fmv.d.x(1)
        LOST    2:  jalr(3)  sd(4)
                

What the compiler was doing before was calling out to a software floating-point library — each call is a jalr — and storing double-width intermediates on the stack, which is four sd. With hardware double-precision floating point those become registers. The instruction count fell by 19 and the byte count by 76, and the letter that did it is one character long.

And the retraction inside the retraction: the first draft of this paragraph said “three auipc disappear”. They do not. The calls are within the ±2 GiB an auipc can reach, so the address-forming pair survives and only the jalr goes. Concept 4 measures that reach.

And: the vector extension is not a subset of anything

    rv64gc     -> rv64gcv     insns   95 ->  178   bytes   246 ->   524
        GAINED 20:  and(3)  beq(3)  bgeu(3)  csrr(3)  divu(2)  j(3)  neg(3)
                    srli(6)  vadd.vv(1)  vfmul.vv(1)  vfmv.f.s(1)
                    vfredosum.vs(1)  vl2re64.v(2)  vlseg2e64.v(1)  vmacc.vv(1)
                    vmv.s.x(3)  vmv.v.i(2)  vmv.x.s(2)  vredsum.vs(2)  vsetvli(4)
                

Twenty mnemonics gained, none of which is an integer instruction, and the instruction count nearly doubles. That is not a regression and it is not a measurement of the vector unit’s speed — it is what happens when a compiler auto-vectorises a loop it was previously leaving scalar, and the vector code is longer than the scalar code was. MEASURED while scoping: a plain double loop under -march=rv64gcv came out as seven vadd and one vsetvli, and the scalar form was longer in bytes.

Which is also the architecture’s sharpest idea, and the reason the count goes up: vsetvli programmes the element width, the register-group count and the tail and mask policies at runtime, so one fixed 32-bit encoding describes a vector of a length the instruction does not contain. Concept 5 is where that encoding is measured.

What this measurement cannot show

This is a large fraction of what this page is for, so it gets its own section rather than a caveat at the end.

Try it

  1. Reproduce the M-is-not-additive row.(Build the corpus yourself: cd courses/rvasm/assets/samples && ./build_samples.sh --only, then clang --target=riscv64-linux-gnu -march=rv64i -S -O2 corpus.c and again with -march=rv64im. Expect the second to be SHORTER, and expect the j/jr to be gone. Then find the shift-and-add sequence the base used — it is four or five instructions where mul is one.)
  2. Read the attribute string and account for all twelve names.(Expect six typed and six inferred, and expect the inferred ones to be the ones that matter to a linker: zca/zcd, zmmul, zaamo/zalrsc, zicsr. Then change one letter and watch which names move — rv64gc adds zifencei over rv64imafdc and changes nothing else.)
  3. Find a feature F does buy and give the corpus one.(Add a float function — not a double one — and rebuild at rv64if against rv64i. Expect flw, fsw, fadd.s and a software-conversion call to appear, and expect the D-row’s story to repeat at a smaller scale. Then revert, because the corpus is a shared input and the committed numbers were taken with the committed corpus.c.)

Connect

Backwards, the ELF course taught you to find .text; the Tag_RISCV_arch string on this page is in .riscv.attributes, which is the same container holding a different kind of fact, and finding it is the same exercise with a different name. The reloc course is where the consequences of this page’s last section live: an attribute string exists because a linker has to refuse to produce something it cannot run.

Forwards, concept 2 takes the letters apart at the bit level — and finds that M, the extension this page measured as “fills a funct7 value the base left empty”, is not even in the same opcode space as the instruction that shares its format. Concept 3 is about the one extension whose cost is a permanent reservation of encoding space rather than a set of instructions.