A follow-up on the immediate encoding mentioned in section 5. I gave it one sentence and a byte count there, which skipped over what the compiler was actually doing. The work on 0.31 makes that worth revisiting.
The original post recorded a 464-byte saving from shorter constant loads. That came from a fairly simple improvement developed on 0.26b and carried into 0.30: stop using two instructions when one will do.
For anyone who doesn’t usually read assembly, an immediate is a number written directly into an instruction. “Add 5 to this register” carries the 5 in the instruction itself. A register is one of the small storage locations the machine uses while calculating.
There is only so much room for the number. RISC-V’s ADDI instruction has a signed 12-bit field, enough for −2048 through 2047. Larger values need another encoding or several steps to construct them.
Assembly makes this look simpler than it is. You can write:
li a0, 5
That asks the assembler to put 5 in register a0. But li is shorthand; the assembler still has to choose the real instructions. In CellScript 0.25, this used the general two-instruction form:
lui a0, 0 # prepare the upper part
addi a0, a0, 5 # add the lower part
By 0.30, the assembler could emit just:
addi a0, zero, 5
The zero register always reads as zero, so that gives us 5 directly. Each removed instruction saves four bytes. The historical relation sample had 116 such opportunities, accounting for those 464 bytes. The much larger 3,968-byte saving in the same section came from removing file padding; that was a separate change. The earlier implementation notes have the breakdown.
Large constants still had a rather expensive fallback. The assembler built them one byte at a time: start with the first byte, shift left by eight bits, add the next byte, and repeat. That produced 15 instructions for a 64-bit value, even when many of those steps were adding zero.
2^56 is a useful example. Written in decimal, it looks large: 72057594037927936. In binary, it is just a 1 followed by 56 zeros. There is no reason to assemble all eight bytes separately when we can do this:
addi a0, zero, 1
slli a0, a0, 56
Put 1 in the register, then shift it left 56 places. The result is identical, but preparing it takes two instructions and eight bytes instead of 15 instructions and 60 bytes.
That is the next step implemented on 0.31. The new planner splits constants into pieces that fit the instructions and combines shifts where it can. It checks that the chosen sequence reconstructs the exact value and compares it with the old sequence, which remains available as a fallback. Arbitrary constants won’t all reduce to two instructions, but simple bit patterns no longer have to pay for the byte-by-byte construction. The planner itself is fairly small.
I wanted to separate that benefit from the other work on 0.31, so the follow-up measurements include a control with only the new immediate planner enabled against the reproduced 0.30 backend:
| Policy fixture | ELF bytes, before → after | Worst successful transaction cycles, before → after |
|---|---|---|
| 8 actions, 32-byte argument | 11,680 → 10,000 | 14,158 → 13,678 |
| 64 actions, 32-byte argument | 73,440 → 61,008 | 29,822 → 26,654 |
In the 64-action fixture, that is about 16.9% fewer bytes and 10.6% fewer cycles from immediate encoding alone. These are specific measured workloads; the result depends on which constants occur and how often the code using them executes.