Keyboard Shortcuts

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

What Compression Does to the ABI and to Disassembly

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

Why this page is a consequence and not a repeat

rv-compressed already measured the encoding side of the C extension: how many code points are reserved, which displacement families share five bit positions and are assigned seven different orders, and the four assembler refusals in the compressed register file. None of that is repeated here. What is measured here is the consequence, which is a different subject with a different kind of evidence: a spilling sequence, a hot loop, a jal/ret pair, and what a disassembler has to do to find an instruction boundary.

Every number on this page is an INSTRUCTION COUNT or a BYTE COUNT. They are NOT a speedup, and the reason is the whole of the first section of this course: there is no RISC-V machine, no emulator and no RISC-V linker on this host, so there is nothing here that could measure one. The x86-64 ABI course measured ratios on hardware it could run; this course has no counterpart and does not estimate one.

The confound, which is the most useful result in the section

Almost every “what does compression cost” measurement in the wild is a comparison of two -march settings that differ in more than one letter. Here, two pairs, and the difference between them is the lesson:

$ python3 rvabi.py --run | sed -n '/file    without C/,+8p'
  file    without C   insns  with C  d-insns  bytes  bytes+c  saved  2-byte
  cpc.c   rv64i       84     80     -4       336    200      40%    75%
  cpc.c   rv64imafd   74     80     +6       296    200      32%    75%
  regs.c  rv64i       165    127    -38      660    350      46%    62%
  regs.c  rv64imafd   127    127    +0       508    350      31%    62%
  abi.c   rv64i       389    368    -21      1556   1296     16%    23%
  abi.c   rv64imafd   368    368    +0       1472   1296     11%    23%
                

rv64i vs rv64gc is CONFOUNDED.rv64i has no F and no D, so every double in the source becomes a CALL to a soft-float helper. The instruction count moves because the arithmetic changed, not because the encoding did. The d-insns column for that pair is large — −38 for regs.c — and every bit of that is the SOFT-FLOAT library and not the encoding. A reader who reported that difference as “what compression costs” would be reporting a floating-point software-library decision as an encoding fact, and both numbers would be real, which is exactly what makes the mistake survive review.

rv64imafd vs rv64imafdc is CLEAN. Same base, same M, same A, same F, same D — and the only difference is the trailing C. There the d-insns column is +0, +0 and +6, and the honest answer is that it is ZERO OR ALMOST: the C extension changes how many BYTES an operation takes, and it does not change how many operations the compiler chose to perform.

And the +6 is the interesting cell, because it falsifies “compression never changes the instruction count” just as surely as the two zeros falsify the opposite. cpc.c at rv64imafdc emits six more instructions than at rv64imafd. The reason is a register constraint: the compressed forms can only name x8 to x15, and a register allocator that has to respect that will sometimes reach for a register the uncompressed encoding would not have needed. So the claim “adding C changes bytes and not instructions” is TRUE for two files of three and FALSE for the third, and the artifact prints that as retraction R5 rather than rounding it away.

Read the two pairs side by side and the lesson is general: a -march comparison is a measurement of ONE extension only when every other letter is held fixed, and “hold every other letter fixed” has to be said out loud because the alternative — comparing the base against a gc build and calling the difference a compression result — is a category error that survives review because both numbers are real.

Three sequences, before and after, instruction by instruction

Same source, -O2, and the same ISA apart from the C. MEASURED-ON-BYTES, decoded rather than disassembled:

$ python3 rvabi.py --run | sed -n '/THE THREE SEQUENCES/,+6p'
  rv64imafd   spill   11 instructions, 44 bytes:
    slli a1,a0,1 | slli a2,a0,32 | add a1,a1,a0 | slli a0,a0,35 |
    add a0,a0,a2 | addi a2,zero,1 | slli a2,a2,32 | add a0,a0,a2 |
    srai a0,a0,32 | add a0,a0,a1 | jalr zero,0(ra)

  rv64imafdc  spill   11 instructions, 26 bytes:
    slli a1,a0,1 | slli a2,a0,32 | c.add a1,a0 | c.slli a0,35 |
    c.add a0,a2 | c.li a2,1 | c.slli a2,32 | c.add a0,a2 |
    c.srai a0,32 | c.add a0,a1 | c.jr ra

  rv64imafd   callret   4 instructions, 16 bytes:
    slli a1,a0,1 | add a0,a1,a0 | addiw a0,a0,2 | jalr zero,0(ra)

  rv64imafdc  callret   4 instructions, 10 bytes:
    slli a1,a0,1 | c.add a0,a1 | c.addiw a0,2 | c.jr ra
                

Eleven instructions and 44 bytes become eleven instructions and 26 bytes. Four and sixteen become four and ten. Every saving here is a c.add for an add, a c.jr ra for a jalr, and a c.addi for an addi — and the instruction count does not move at all.

Read the call/return pair twice, because it is the most compressed thing in the corpus and for a reason that is about the ABI rather than about the encoding. jalr zero, 0(ra) becomes c.jr ra: the compressed form has no operand for the link register because the cell is the return, and that is the same design decision that put the return address in a register in the first place. And addiw a0, a0, 2 becomes c.addiw a0, 2 — the compressed word form exists precisely because RV64 has a 32-bit addw and 32-bit operations are overwhelmingly the common case in compiled code.

The spill, and the row that falsifies the easy claim

$ python3 rvabi.py --run | sed -n '/AND THE SPILL, WHICH/,+6p'
  rv64imafd   spill    11 instructions, 44 bytes, 0 stores
  rv64imafdc  spill    11 instructions, 26 bytes, 0 stores

  rv64imafd   spilln   43 instructions, 172 bytes, 1 stores
  rv64imafdc  spilln   49 instructions, 124 bytes, 4 stores
                

spilln is the function that spills eight callee-saved registers, and the numbers are the most interesting on this page: 124 bytes against 172 is a 28% saving, and the instruction count went UP, from 43 to 49. Six more instructions, four more stores, and a frame that is smaller in the compressed build.

So a callee that spills eight registers does more work in the compressed build than the same callee in the uncompressed one, and the reason is not that compression made anything slower. The compressed store forms have register constraints — c.sd and c.sw can only name x8 to x15 — and when the value being saved is not in that window the allocator has to move it there first, and the move is an instruction. The compressed encoding is not uniformly shorter per operation: it is shorter per operation THAT IT ENCODES, and the register allocator reacts to that by storing differently. That is retraction R7, and it is a retraction because the obvious prediction “the callee that spills less does less work” measured the other way round.

The hot loop, whose BODY is the thing that repeats

A whole-function byte count measures the wrong code: the prologue runs once and the body runs n times. So here is the body — the instructions between the first branch and the backward branch, which is the part that repeats:

$ python3 rvabi.py --run | sed -n '/THE HOT LOOP/,+5p'
  rv64imafd   hot: 12 instructions, 48 bytes whole; the loop body is
              11 instructions and 44 bytes
  rv64imafdc  hot: 12 instructions, 30 bytes whole; the loop body is
              11 instructions and 28 bytes
                

44 bytes of body become 28. And read which instructions did not compress: lw a3, 0(a0) stays four bytes as c.lw a3, 0(a0) — because the register constraints refuse it — and slli a4, a3, 1 stays four bytes as well, because the shift amount does not fit the compressed six-bit field. Compression is not a uniform discount on a function; it is a per-instruction question, and the answer is “no” a surprising fraction of the time. That is the encoding side arriving as a consequence.

The boundary problem, which is where this stops being about the ABI

On x86-64 and AArch64 an instruction’s length is a CONSTANT, so a disassembler’s loop is:

    p += 4
                

and the loop cannot be wrong. On RISC-V an instruction is TWO or FOUR bytes and the loop is:

    if (halfword & 3) == 3:  p += 4
    else:                      p += 2
                

which needs TWO BITS OF THE CURRENT POSITION and nothing else. That is the whole of the guarantee, and it is enormous: a word the decoder cannot name at all still advances p by its own length, so an unmodelled instruction is a gap in the NAMES rather than a gap in the WALK. This is rvdec.py’s central problem and it is the reason a RISC-V disassembler is a different program from an AArch64 one.

And the demonstration is measured rather than asserted. The artifact walks every code section of every corpus object with the rule, and again with the length SUPPLIED from outside, and reports how many instructions each walk found:

$ python3 rvabi.py --run | sed -n '/instructions found by the RULE/,+6p'
  object             by the RULE   with the length SUPPLIED   verdict
  probe.o            18            13                          DIFFERENT
  abi_O0.o           927           748                         DIFFERENT
  abi_O2.o           368           324                         DIFFERENT
  noflags_O2.o       74            46                          DIFFERENT
  regs_O2.o          127           87                          DIFFERENT
  ...
  6048 instructions found by the two-bit rule and 5012 found with the
  length handed in, across 25 code sections of 25 objects.
                

6,048 against 5,012 — and they are NOT equal, which is the point. If they were equal, the rule would be doing no work: the count would be entirely explained by the symbol table’s instruction sizes. The supplied version is what a disassembler with a symbol table can do and the rule version is what one without must do, and the difference of 1,036 instructions is the population where the two disagree about where a boundary is. So the rule is load-bearing, and it is TWO BITS.

A reader arriving from AArch64 — where the loop is p += 4 and cannot be wrong — has to learn this from an encoding rather than from an argument, because there is no RISC-V machine here to run the wrong answer on. And that is the honest end of the concept: the cost of compression is measurable in bytes, and the cost of NOT having compression is measurable in a two-bit rule, and neither of them needs hardware.

And what none of this can show, stated where a reader would otherwise expect the number: fewer bytes is a statement about instruction FETCH, and even that needs a memory system this course cannot measure. Fewer bytes, the same operations, and no timing. The honest sentence under that table is exactly that one, and a course that put a ratio there would be inventing a measurement it did not take.

Try it

  1. Break the length rule and watch the instruction count move.(This is the artifact’s own poison 3, and it is four lines of code. Replace p += 4 with p += 2 in the rule and re-run: the walk will desynchronise, and the cross-check’s named-instruction count will collapse while the disagreement count stays at zero — because the two readers would be reading the same wrong boundaries. That is the failure mode a decoder has that a timing measurement does not: it can be confidently wrong and self-consistent. Then restore it and confirm the count returns.)
  2. Build the confounded pair yourself, and then the clean one.(Compile regs.c at -march=rv64i and at -march=rv64gc, count instructions, and get −38. Then compile at -march=rv64imafd and -march=rv64imafdc and get zero. The two answers differ by 38 and the difference is entirely floating-point software. Hold every other letter fixed or do not report the comparison — and the way to know you did not hold them fixed is that a number is suspiciously large.)
  3. Find the instruction that refuses to compress and say why.(In the hot loop above, lw a3, 0(a0) does not become two bytes. It is not because the instruction is important; it is because c.lw can only name rd' in x8–x15 and a0 and a3 are outside that window. Now write the same load with a pointer in s0 and a destination in a2 and watch the same instruction become two bytes. Compression is a property of the register numbers a program happens to use, and that is why the “percentage of bytes saved” column is a statement about one compiler’s register allocation.)

Connect

Backwards, rv-compressed is the encoding half this page is the consequence of, and the two are deliberately not merged: reserved code points and displacement permutations are one subject, and what a spill and a hot loop actually become is another. Concept 3 is where the x8–x15 window is introduced as a register-file fact, and this page is where it turns into an instruction-count difference.

Across architectures, isa-length is the general question this page answers for one architecture, and a64-encoding is the case where the answer was p += 4 and the field map is the curriculum. RISC-V has the same goal as AArch64 — keep the decoder simple — and pays for it differently: not with a constant length, but with a two-bit rule that is cheap to state and impossible to verify by eye.

And rv-verify is where the unmodelled words in that 6,048-versus-5,012 difference get named. This page counts the gap; that one decodes what is in it.

Forwards, this is the last RISC-V concept, and the thing it hands to the next architecture is a habit rather than a fact: when you see a size comparison, ask which other things changed at the same time. The -march confound above is the same shape as the soft-float surprise in rv-isa and as the F/D removal in the ABI course before it, and all three are the same mistake wearing different clothes.