After months of trying to get around to it, I finally have a straw-man level system of compressing RISC-V code by squashing pairs of opcodes into a single 32-bit packet rather than squashing individual opcodes into 16-bit packets in order to address issues with unaligned 32-bit instruction words.

The intent is to make a minimal implementation possible which can decode the first slot easily while simultaneously reformatting the packet to expose the B slot for interpretation by the same decoder on the next step. But also to make compressed code more digestible for wide, multi-issue implementations.

Not to imply that performance cores want compressed code. Merely that if they’re stuck with it as a compatibility constraint then it shouldn’t be so painful.

It’s still not a fully defined thing. Just a thought experiment gone a little too far.

This takes 1/4 of the total instruction coding space, meaning it steals 1/3 of the space occupied by RVC. It’s assumed that the remaining 2/3 of RVC won’t be used in conjunction with this as that would undermine the objectives given below.

Why?

To make compressed code less of an impediment to high-end cores, while teasing a couple of side benefits. In particular:

  • To avoid the problems of unaligned 32-bit instructions
  • To avoid the other problems with mixed-size instructions
  • To exploit inter-opcode redundancies
  • To use much less opcode space
  • To dress up like a CISC architecture in order to gain its powers
  • To maintain the conventional 2-source, 1-destination data flow model by executing 32-bit packets sequentially as two separate instructions
  • To expose macro-op fusion opportunities within a 32-bit word when executing whole packets at once

Basically, I saw too many complaints about the various consequences of 16-bit aligned instructions mixed with 32-bit aligned instructions, and so I started thinking about it myself, and did some very rough hacking to see how I might solve it, and now I’m at the stage which I present here, which I hope is good enough to get the general idea across and demonstrate that the compression can work under the given constraints.

If you’re coming to this from the point of view of out-of-order wide multi-issue pipelines then I’m hoping you’ll be able to just chuck whole 32-bit packets through the front end as single instructions and then do µ-op fission in the usual way. Control flow only changes between whole packets.

Status

This is just an exploration, not a formal RISC-V proposal. All things are subject to change and much needs to be solidified before it can be implemented.

The results here are intended to establish the viability of the compression and to give a view on mindset and intent which motivated decisions, compromises, and blind hope.

Most of this document focuses on the cheap end, and how it might minimise the cost of implementation. Performance cores just get the occasional hint here and there about shortcuts they might take to avoid pain.

Some features here fall in the “totally unacceptable” category, and that’s OK. They just fill in gaps and highlight what needs further work. Some things have turned out to be less untenable than I expected.

Packet structure

A packet comprises two instructions compressed into a 32-bit instruction word. There are four register fields (the three standard ones, plus one more in part of funct7) which are shared between two regular RISC-V instructions:

funct7 rs2 rs1 funct3 rd opcode implicit r_extra

The mapping of fields to different instruction operands is described by a frame. Different frames and different opcode pairs within each frame are currently enumerated in the ten free bits in opcode, funct3, and two bits of funct7 (two bits of opcode are reserved to identify this encoding scheme). More on “enumeration” and its decoder implications later.

No new instruction semantics are introduced; some optional instructions are included. A frame merely compressed two consecutive instructions into one 32-bit packet. The first instruction (generally) has its source register fields aligned to the source register fields of 32-bit opcodes, but the usual destination register field is (generally) the register written by the second instruction. Other fields are recycled and repurposed according to the specific frame structure they follow.

For example, a frame may designate a source register as also the destination register to save encoding space, or elide the destination register slot the first instruction and a source register slot of the second instruction and hard-code them as a temporary register.

Immediates are 5-bit by default, aliasing with a register index. When this is insufficient the frame may repurpose another register field to make a ten-bit immediate. When ten bits is too many (e.g., the index for bit shifts on a 64-bit platform) the immediate-consuming instruction is duplicated in the list of opcodes the frame supports, and the choice between the two (or four, or eight) copies of the same instruction serves as an extra bit (or two or three).

For chain rules, temporary storage is used and not encoded in the packet at all. It’s left to the implementation to decide what kind of path this data takes, with the caveat that it must be exposed architecturally for exceptions. The possibility is held open for the decoder to hard-code the temporary register as x31 or x7 if this does not impede optimisation (see Exceptions and interrupts).

Frame structures are signalled by the opcode field. Each frame has a number of opcode pair configurations it supports, and these are enumerated and rounded up to a power of two.

Instruction pairs only allow control flow changes in the second slot. Branch targets are always 32-bit aligned.

A frame might pose a question like “how do we implement load with base address write-back?” and then encapsulate the two instructions which implement that (load and addi) by re-using operands from the first instruction in the second (rbase -> rdest, rbase -> rs1, offset -> immediate), and then unroll that across all the opcodes (lb, lbu, lh, etc..) it needs to complete the set.

Typical savings come from sharing rd and rs1 (as rsd) encoding, and from ‘chain’ rules where the first result is used only by the second operation before being discarded, and so neither the destination register nor its reference in the next op need to be encoded explicitly. Implicit sp is used a lot as well, but that saving is usually spent on a longer offset for large stack frames.

Decoding and execution

In the simplest implementation an instruction decoder would decode the first slot normally (with the caveat that the destination register is not in the usual location), while also preparing another conventional 32-bit instruction word to feed back to the same instruction decoder for the next decode step.

Alternatively, an implementation might ingest the packet as a single instruction and split it into µ-ops at a more convenient stage in the pipeline. A more aggressively optimised implementation could fuse them into a single operation when practical.

Regardless of the implementation a valid packet has the architectural effect of two ordinary RISC-V instructions executed sequentially, each consuming its operands and producing its normal architectural result in turn, with slot B observing the effects of slot A. Some combinations may have to be excluded from permitted encodings if they have problematic implications in high-performance implementations. The intent is to capture such cases explicitly if they’re common but not to leave them as performance landmines if they’re rare.

Similarly, some packets, like rsd-alu-pair, specify separate destination registers for each slot, meaning they’re able to encode cases where the second slot must use the result of the first. This might offer code density gains but could raise pipeline headaches, so encoding such dependencies should probably be prohibited. If it remains legal then it must still behave as if sequential.

At present there’s no optimisation for ease of decode, other than an attempt to align the operand fields consistently. The remaining ten bits are a naive enumeration of all the allowed permutations, with little organisation into bits. That’s to be addressed later, but ten bits is at least better than 32.

Exceptions and interrupts

If an exception occurs inside a packet then the architectural state must be properly resolved such that execution can resume at the appropriate slot within a packet, as needed. If the first slot did not cause the exception then architectural state must be updated accordingly, as if the two slots execute sequentially.

Since a restart needs to distinguish which instruction caused the fault, bit 1 of the PC can be used to signal slot B (dressing up as if we’re executing two 16-bit instructions). This mechanism is only required for restarts and doesn’t have to have optimal performance. Normal branch and jump targets are always 32-bit aligned, and for all purposes outside of exception and interrupt handling PC can safely be assumed to be 32-bit aligned.

Interrupts are assumed to follow the same protocol as exceptions. It is hoped (TBD) that an implementation would be able to meet the architectural model while deferring interrupts until completion of the whole packet in order to avoid complexity.

The other obvious state needed for a restart is the temporary value used in chain operations.

Logically one could hard-code this temporary during instruction decode as x31 (which doesn’t exist on RV32-E, so maybe x7 instead?) so it’s automatically saved; but some implementations may not appreciate this constraint, and it would be preferable for such a named register to not be guaranteed to retain a particular value after chain packets in normal operation. The intermediate value should only be considered reliably written back when forced by an exception or interrupt.

Alternatively, just expose it in a CSR. I’m not sure what’s best for the most economical implementations.

Another alternative for exceptions, if an implementation doesn’t want to deal with the complexity at all and has the necessary machinery, could be to cancel the whole packet (reversing the effects of slot A where necessary) and for the exception handler to simulate the pair of instructions one at a time.

Breakpoints

I assume these can be handled much the same way as exceptions, in the case of hardware breakpoints, or with a couple more rounds of rewriting in-place using standard 32-bit instructions.

The encoding (provisional)

Here’s what I came up with. Opcodes are enumerated simply to demonstrate that they can actually be encoded into 32 bits while the frames continue to evolve. The current assignment of the bit values is not how it should be done, just something expedient.

Bits marked p below are the bits used to enumerate the opcode combinations available in each frame. The values for the integer stored in the p bits are given in the tables following the frame structure. It’s just a provisional assignment and hasn’t been tuned for a realistic decoder.

Where the enumeration jumps by more than one, and where instructions are marked “×n”, that’s where bits of the opcode enumeration have been taken to extend the immediate range. The plan, here, is to align the bits which choose between duplicate opcodes to always land in the same positions in every frame, so that immediates decode consistently (give or take masking to the proper length).

frame layouts

alu-alu-chain

Two ALU operations, the second consuming the first’s result.

0 1 0 p p p p p p p 1 0 rs2a rs1a tmp alu tmp, rs1a, rs2a rs2b rdb tmp alu rdb, tmp, rs2b 0 1 0 p p p p p p p 1 0 imma rs1a tmp alu tmp, rs1a, imma rs2b rdb tmp alu rdb, tmp, rs2b 0 1 0 p p p p p p p 1 0 rs2a rs1a tmp alu tmp, rs1a, rs2a immb rdb tmp alu rdb, tmp, immb 0 1 0 p p p p p p p 1 0 imma rs1a tmp alu tmp, rs1a, imma immb rdb tmp alu rdb, tmp, immb
slot Bslot A
addi ×2 add andi slli sltu srliw sub
addi ×2 0 2 3 4 5 6 7
add 16 18 19 20 21 22 23
and 24 26 27 28 29 30 31
or 32 34 35 36 37 38 39
slli 40 42 43 44 45 46 47
srli 48 50 51 52 53 54 55
sub 56 58 59 60 61 62 63
slot Bslot A
addi ×2 and or sltiu srli xor xori
addi ×2 64 66 67 68 69 70 71
add 80 82 83 84 85 86 87
andi 88 90 91 92 93 94 95
or 96 98 99 100 101 102 103
slli 104 106 107 108 109 110 111
sltiu 112 114 115 116 117 118 119
xor 120 122 123 124 125 126 127

index-mem-chain

Scaled-index addressing: compute base + i*width and access it.

1 0 1 1 1 1 1 p p p 1 0 rs2a rs1a tmp shXadd tmp, rs1a, rs2a immb rdb tmp load rdb, k*immb(tmp) 1 0 1 1 1 1 1 p p p 1 0 rs2a rs1a tmp shXadd tmp, rs1a, rs2a rs2b immb tmp store rs2b, k*immb(tmp)
slot Bslot A
add
lbu 0
sb 1
slot Bslot A
sh1add
lhu 2
sh 3
slot Bslot A
sh2add
lw 4
sw 5
slot Bslot A
sh3add
ld 6
sd 7

pre-inc-pair

Advance a pointer, then access through it (pre-increment).

1 0 1 1 1 1 0 p p p 1 0 rsda rs1a shXadd rsda, rs1a, rsda immb rsda rdb load rdb, k*immb(rsda) 1 0 1 1 1 1 0 p p p 1 0 imma rsda addi rsda, rsda, k*imma rsda rdb load rdb, 0(rsda) 1 0 1 1 1 1 0 p p p 1 0 rsda rs1a shXadd rsda, rs1a, rsda rs2b rsda immb store rs2b, k*immb(rsda) 1 0 1 1 1 1 0 p p p 1 0 imma rsda addi rsda, rsda, k*imma rsda rs2b store rs2b, 0(rsda)
slot Bslot A
addi
ld 0
sd 1
slot Bslot A
addi
lw 2
sw 3
slot Bslot A
sh3add
ld 4
sd 5
slot Bslot A
sh2add
lw 6
sw 7

Notes and observations (mostly AI)

  • The addi rows access AT the bumped pointer: at genuine (non- prologue) surviving-sum sites the memory offset is zero 68-78% of the time, so the rows spend no immb and give the bump the freed column instead – a 10-bit width-scaled imma, which the op declares at no extra codepoints. Even so the bump population is structurally wide (record-sized walks: 10-bit scaled fit is 39-59%); this is the affordable ceiling, not full coverage.
  • The shXadd rows keep the 5-bit scaled immb: their stride is the register, so the offset field still earns its column.
  • The shXadd form is the scaled POINTER WALK, shXadd rsda, rs1a, rsda (Zba: rd = rs2 + (rs1 « X)) – the pointer advanced by a scaled stride, which is what “advance a pointer” means and the form the corpus emits (its in-place-scaling sibling shXadd a, a, x measured 69 scheduled pairs on sqlite-rv64 against this form’s 210; one row decodes one operand binding, so rules.py admits only this one). The row is drawn at the STANDARD Zba read ports: the shifted stride rs1a in the rs1 column, the pointer rsda in rs2 – Zba’s rs2 position. B needs no port for its base at all: a pre-increment accesses through the UPDATED pointer, which is A’s result, forwarded inside the packet – so nothing competes with A for the columns and the operand-position discipline holds without a trade.

    post-inc-pair

    Access through a pointer, then advance it (post-increment).

1 1 0 0 0 1 0 1 p p 1 0 imma rsda rda load rda, k*imma(rsda) immb rsda addi rsda, rsda, k*immb 1 1 0 0 0 1 0 1 p p 1 0 rs2a rsda imma store rs2a, k*imma(rsda) immb rsda addi rsda, rsda, k*immb
slot Bslot A
ld sd
addi 0 1
slot Bslot A
lw sw
addi 2 3

Notes and observations (mostly AI)

  • Timing oddity here because reg in rd field is written in first cycle not second.
  • No shXadd clusters: a post-increment by a register-held stride is a real idiom, but neither clang nor GCC emits it adjacent to the access (zero scheduled pairs on every corpus).
  • Both fields earn their columns, unlike pre-inc: the access offset imma is the position inside an unrolled window (small, 67-74% within +/-16) and the stride immb is the window size (wide). They decouple under unrolling – stride = offset + width holds at only 3-19% – so neither a zero offset nor a delta encoding works here.

    mem-sp-pair

    Two adjacent stack accesses one word apart – a spill or reload pair.

1 1 0 0 0 1 1 1 0 p 1 0 imm rda sp load rda, k*imm(sp) imm rdb sp load rdb, k*imm+k(sp) 1 1 0 0 0 1 1 1 0 p 1 0 imm[9:5] imm[4:0] rs2a sp store rs2a, k*imm(sp) imm[9:5] imm[4:0] rs2b sp store rs2b, k*imm+k(sp)
slot Aslot B
lxlx0
sxsx1

Notes and observations (mostly AI)

  • both opcodes in a pair must be identical operations
  • offsets differ by one data width, as in mem-base-pair

    mem-base-pair

    Two adjacent accesses through one base register, one data width apart.

1 0 1 1 0 1 p p p p 1 0 rda imm rbase load rda, k*imm(rbase) imm rbase rdb load rdb, k*imm+k(rbase) 1 0 1 1 0 1 p p p p 1 0 rs2a rbase imm store rs2a, k*imm(rbase) rs2b rbase imm store rs2b, k*imm+k(rbase)
slot Aslot B
lbulbu0
lhulhu1
lwlw2
ldld3
sbsb4
shsh5
swsw6
sdsd7

Notes and observations (mostly AI)

  • both opcodes in a pair must be identical operations
  • No lb, lh or lwu: they account for 12 of 37816 scheduled slots across musl-rv32 and sqlite-rv64.

    load-alu-chain

    Load a value and immediately compute with it.

0 1 1 0 p p p p p p 1 0 imma rs1a tmp load tmp, k*imma(rs1a) rs2b rdb tmp alu rdb, tmp, rs2b 0 1 1 0 p p p p p p 1 0 imma rs1a tmp load tmp, k*imma(rs1a) immb rdb tmp alu rdb, tmp, immb
slot Bslot A
lw ×2 ld ×2
addi ×2 0 2
add 8 10
addw 12 14
and 16 18
andi 20 22
maxu 24 26
or 28 30
slli 32 34
sltiu 36 38
sltu 40 42
srli 44 46
srliw 48 50
sub 52 54
xor 56 58
xori 60 62

alu-store-chain

Compute a value and store it.

1 0 0 1 0 p p p p p 1 0 rs2a rs1a tmp alu tmp, rs1a, rs2a rs1b immb tmp store tmp, k*immb(rs1b) 1 0 0 1 0 p p p p p 1 0 imma rs1a tmp alu tmp, rs1a, imma rs1b immb tmp store tmp, k*immb(rs1b)
slot Bslot A
addi ×2 add addw and andi maxu or slli sltiu sltu srli srliw sub xor xori
sw 0 2 3 4 5 6 7 8 9 10 11 12 13 14 15
sd 16 18 19 20 21 22 23 24 25 26 27 28 29 30 31

addi-store-chain

Form a value – constant, copy or sp-relative address – and store it.

1 1 0 0 0 1 0 0 p p 1 0 imma rs1a tmp addi tmp, rs1a, imma rbase tmp store tmp, 0(rbase)
slot Bslot A
addi
sb 0
sh 1
sw 2
sd 3

Notes and observations (mostly AI)

  • The data width comes from the op list (sb/sh/sw/sd), as in the other memory frames, rather than a width field – 4 codepoints is cheaper than two bits of layout, and it matches existing convention. sh captures nothing on this corpus but only 3 sites carry it, which is too thin to conclude it never would.
  • A covers li (rs1a = x0), mv (imma = 0) and addi4spn (rs1a = sp) as register/immediate choices, so they need no opcodes of their own.

    addi-store-off-chain

    Compute a value from one base and store it at an offset from another.

1 0 1 1 1 0 1 p p p 1 0 imma rs1a tmp addi tmp, rs1a, imma rbase immb tmp store tmp, k*immb(rbase)
slot Bslot A
addi
sb ×2 0
sh ×2 2
sw ×2 4
sd ×2 6

Notes and observations (mostly AI)

  • immb is width-scaled and unsigned; imma is signed – and that asymmetry is the frame’s whole shape. A memory offset carries an access width, so five bits of immb reach 4x or 8x further; an addi addend is pointer arithmetic and carries none, so its bits are bytes. load5-load5-chain gets a symmetric split because BOTH its immediates are scaled offsets; this frame cannot, and scaling A by the store’s k would not help – the addends are 93% ODD, only 28.8% are aligned to their own store’s width, and for sw (65% of the population) it is 5 of 295. The stored value is the chain temporary and is not encoded.
  • THE WIDTH BELONGS TO B, measured 2026-08-04. The row draws five bits per column, so a sixth costs a doubling. Over 455 scheduled pairs on sqlite-rv32/rv64 and cpp-rv32/rv64: A’s addend fits FIVE bits 97% of the time and two bits 95% of the time, while B’s scaled offset fits five only 33% of the time and six 100%. So A was paying 8 codepoints for the 2% that need a sixth bit. Dropped to 5+6: 16 codepoints to 8, at a cost of 5 pairs on sqlite-rv32 and 4 on sqlite-rv64. Narrowing B too (5+5, 4cp) costs a further 135 and 130 – that bit is real.
  • This is a SQLITE-shaped frame: 212 hits on sqlite-rv32 against 12 on cpp-rv32. A cpp-only reading makes it look like the worst frame in the encoding; it is not.
  • rules.py EXCLUDES the li form (rs1a = x0) here even though the row draws rs1a and could encode it: li + store belongs to the frames that price it (addi-store-chain at offset zero, alu-store-chain up to a 5-bit offset). The residue – li + store at an offset only this frame’s sixth bit reaches – stays solo.

    load-store-chain

    Copy a value from one memory location to another.

1 0 1 0 0 1 p p p p 1 0 imma rs1a tmp load tmp, k*imma(rs1a) rs1b immb tmp store tmp, k*immb(rs1b)
slot Aslot B
lbusb ×40
lhush ×44
lwsw ×48
ldsd ×412

Notes and observations (mostly AI)

  • Both offsets are width-scaled and unsigned; the loaded value is the chain temporary and is not encoded.

    load0-load10-chain

    Pointer chase: bare first load, the second carries a wide offset.

1 1 0 0 0 0 0 p p p 1 0 rs1a tmp lx tmp, 0(rs1a) immb rdb tmp load rdb, k*immb(tmp)
slot Bslot A
lx
lb 0
lbu 1
lh 2
lhu 3
lw 4
lwu 5
ld 6

Notes and observations (mostly AI)

  • The A slot spends ONE opcode, not seven. must_chain_base makes A’s loaded value B’s base address, and a byte or halfword is not an address – so A is the natural word by construction. Measured over every chain the pairer can form, all 11583 of them across the suite, A is lw on RV32 and ld on RV64 100.0% of the time, with no exception on or off the axes. That is what lx names, and it takes the block from 7x7=49 codepoints to 1x7=7.
  • immb gets the full ten bits – five from funct5, five from rs2, fields the pair leaves free because tmp is implicit and A’s offset is pinned at zero. This single form is 59.9% of all chases.
  • Split from a frame that drew BOTH this row and its sibling’s over one 49-codepoint op-select, with nothing selecting between them: a decoder holding the word could not tell whether the field was the first load’s offset or the second’s. That frame’s standing TODO – balance imma against immb – is answered in results/corpus/CHAINS.md.

    load5-load5-chain

    Pointer chase with BOTH loads offset: a pointer in a slot, then indexed.

1 1 0 0 0 0 1 p p p 1 0 imma rs1a tmp lx tmp, k*imma(rs1a) immb rdb tmp load rdb, k*immb(tmp)
slot Bslot A
lx
lb 0
lbu 1
lh 2
lhu 3
lw 4
lwu 5
ld 6

Notes and observations (mostly AI)

  • The offset-bearing sibling of load0-load10-chain, on the pattern of addi-store-off-chain. It replaces an earlier deref-load-chain, whose population (offset on the FIRST load, second at zero) is the immb=0 column here – 2397 of its 2425 chases, the 28 lost being those needing more than five bits of imma.
  • The ten free bits are split evenly because the corpus says so, not for symmetry. load0-load10-chain has already absorbed the whole imma=0 row, so what is left to catch is diagonal mass, and it is spread: measured corpus totals are 5+5 505255, 6+4 505073, 4+6 505032, 7+3 504522, 3+7 504635. Eleven bits (5+6) would reach ~10977 chases and cost an opcode doubling for ~154 pairs.
  • rs1a = sp is what makes this frame necessary rather than a rounding error. An sp-based chase – a pointer read out of a stack slot, then indexed – is 58% both-offsets-nonzero and 1% B-only, against 20% and 60% for a non-sp chase. The slot displacement is an A offset by construction.
  • The two frames are disjoint by construction: this one demands imma be nonzero and its sibling demands it be zero, so no chase is encodable both ways and neither shadows the other.

    load-base-branch-pair

    Load a value and branch on whether it is zero; the value survives.

1 0 1 0 0 0 p p p p 1 0 rda imma rs1a load rda, k*imma(rs1a) rda immb zero beqz/bnez rda, zero, 4*immb
slot Bslot A
lb lbu lh lhu lw lwu ld
beqz 0 1 2 3 4 5 6
bnez 7 8 9 10 11 12 13

Notes and observations (mostly AI)

  • immb is the branch displacement, a 5-bit field. Displacements are unresolved labels in the corpus, so their fit is unmeasured.

    load-sp-branch-pair

    Load a stack slot and branch on whether it is zero; the value survives.

1 0 1 1 1 0 0 p p p 1 0 imma rda sp load rda, k*imma(sp) rda immb zero beqz/bnez rda, zero, 4*immb
slot Bslot A
lw ld lbu
beqz 0 1 2
bnez 3 4 5

Notes and observations (mostly AI)

  • immb as in load-base-branch-pair: unresolved, fit unmeasured.

    inc-branch-pair

    Step a loop counter by one and branch on the comparison.

1 0 1 1 0 0 p p p p 1 0 rsda inc/dec rsda immb[9:5] immb[4:0] rs2b rsda bXX rsda, rs2b, 4*immb
slot Bslot A
inc
beq 0
bne 1
blt 2
bltu 3
bltu_r 4
bge 5
bgeu 6
bgeu_r 7
slot Bslot A
dec
beq 8
bne 9
blt 10
bltu 11
bltu_r 12
bge_r 13
bgeu 14
bgeu_r 15

Notes and observations (mostly AI)

  • The step is +/-1, implied by the opcode (inc/dec = addi rsda, rsda, +/-1): 88% of adjacent counter-branch sites compare against a REGISTER, so the immediate column goes to rs2b instead of a step field; rs2b = x0 gives every vs-zero form for free. Full XLEN width only – there are no w forms.
  • _r marks the operand-reversed spelling (the counter in rs2). The two clusters are the best sixteen JOINT direction x mode cells of the adjacent-site census, not a mode product: down-loops are bltu/bgeu-heavy (pointer-vs-limit, both operand orders), up-loops beq/bne with bge/bgeu sum-first. Joint enumeration covers 98.7% of adjacent sites against ~79% for the best 4-mode x 2-direction product at the same sixteen entries.
  • The scheduler also matches addiw rsd, rsd, +/-1 and bills it here. That is optimistic for unsigned int counters on rv64 (defined wrap is not width-equivalent) in the same spirit as RVC- eligibility; signed counters are provably width-equivalent (overflow is UB), so a packet-targeted compiler emits addi.
  • immb is the branch displacement in packets. Displacements are unresolved labels in the corpus, so pairwise fit is unmeasured; the label-distance study puts 10-bit fit near 100%.

    bit-test-branch-chain

    Test a bit or bit-field and branch on the result.

1 0 0 0 0 p p p p p 1 0 imma rs1a tmp andi tmp, rs1a, imma immb[9:5] immb[4:0] tmp beqz/bnez tmp, 4*immb 1 0 0 0 0 p p p p p 1 0 imma rs1a tmp slli/srli tmp, rs1a, imma immb[9:5] immb[4:0] tmp beqz/bnez tmp, 4*immb
slot Bslot A
andi ×2 slli ×2 srli ×2
beqz 0 2 4
bnez 6 8 10
beq 12 14 16
bne 18 20 22

Notes and observations (mostly AI)

  • The shift forms are the E1/E2 rewrite targets (low-mask and high-mask zero tests); a single-bit sign test via slli + blt tmp, zero is equivalence E5, a candidate this frame does NOT yet encode – its b list has no blt/bge – and rules.py matches accordingly.

    li-branch-chain

    Compare a register against a constant and branch.

0 1 1 1 p p p p p p 1 0 imma tmp li tmp, imma immb[9:5] immb[4:0] rs1b tmp bXX rs1b, tmp, 4*immb
slot Bslot A
li ×8
beq 0
bne 8
blt 16
bge 24
bltu 32
bgeu 40

Notes and observations (mostly AI)

  • imma is a 5-bit register column; li declares 8 bits, bought by three opcode doublings (census li fit 66.9% -> 85.3% of 2293, ~13 pairs/codepoint for the extra 32).
  • The row spells the constant in rs2. A site with the constant on the LEFT of an asymmetric compare (blt tmp, rs) is still encodable via the dead-tmp rewrite bXX K, rs -> bYY rs, K+1 (blt<->bge, bltu<->bgeu) – tmp carries only the comparison constant and dies at B, so changing its value is licensed. rules.py accepts those sites and rejects the two edge cases the rewrite cannot reach: K at the top of the field (K+1 overflows) and K = -1 under an unsigned compare (the predicate flips).
  • TODO: could replace li with alu op and compare result with zero (mostly?).

    li-czero-chain

    Materialise a constant and conditionally zero it – one arm of a select.

1 1 0 0 0 1 1 0 0 p 1 0 imma tmp li tmp, imma rs2b rdb tmp czero.X rdb, tmp, rs2b
slot Bslot A
li
czero.eqz 0
czero.nez 1

czero-or-chain

Finish a conditional select: merge the surviving arm into the result.

1 1 0 0 0 1 1 0 1 p 1 0 rs2a rs1a tmp czero.X tmp, rs1a, rs2a rs2b rdb tmp or rdb, tmp, rs2b
slot Bslot A
czero.eqz czero.nez
or 0 1

macro-op-pair

Both halves of ONE computation over the same operands (mul/mulh, div/rem), declared as a pair so an implementation can fuse them.

1 0 1 0 1 0 p p p p 1 0 rda rs2a rs1a alu rda, rs1a, rs2a rs2a rs1a rdb alu rdb, rs1a, rs2a 1 0 1 0 1 0 p p p p 1 0 rda rs2a rs1a add rda, rs1a, rs2a rda rs1a rdb sltu rdb, rda, rs1a
slot Bslot A
mulh mulhu mulhsu
mul 0 1 2
slot Bslot A
add addw
sltu 4 5
slot Bslot A
div
rem 6
slot Bslot A
divu
remu 7
slot Bslot A
divw
remw 8
slot Bslot A
divuw
remuw 9

Notes and observations (mostly AI)

  • CARRY-OUT, measured (2026-08-04). cpp-rv32 holds 156 carry-shaped adjacencies, godot 36, everything else under five. The cluster takes the frame from 59 hits to 167 on cpp-rv32, but the corpus total rises by 29. The difference is NOT another frame losing the same pairs – alu-alu-chain cannot encode (add, sltu) at all, since sltu appears only in its A sets. It is greedy DISPLACEMENT: claiming the add denies it to whatever was pairing with it from the left, so alu-alu-chain drops 74 elsewhere in the stream. Report the frame’s worth as 29, not 108.
  • THE REST OF THE FRAME IS KEPT DESPITE A NEAR-ZERO SCORE. Do not cut the mul/div clusters on pairing-rate evidence; they are not there to earn pairs.
  • Every cluster is two halves of ONE computation over the same operands: the low and high words of a multiply, the quotient and remainder of a divide, the sum and difference, the min and the max. Encoding them as a declared pair tells the implementation both results are wanted, so it can FUSE – one pass of the multiplier or divider producing both halves – instead of issuing the operation twice and discarding half of each result. That is a hardware invitation, and it is worth a codepoint block whether or not today’s compilers accept it.
  • They mostly do not, yet. Measured over four corpora: 70 scheduled pairs. The ceiling is no higher – adjacent tuple matches with positionally shared operands number 31 on musl-rv32, 25 on cpp- rv32, 2 on sqlite-gcc-rv64, 0 on sqlite-rv64 – so nothing is suppressing it. Notably the frame is NOT register-window constrained: this row draws four full 5-bit fields, so every register encodes.
  • ORDER IS NOT ARBITRARY, and it is not a dependency either. The two ops read the same two sources and neither consumes the other’s result, so they commute – but the RISC-V M extension names one sequence as THE fusion idiom, and a microarchitecture told to detect fusable pairs is looking for that one: MULH[[S]U] rdh, rs1, rs2 ; MUL rdl, rs1, rs2 DIV[U] rdq, rs1, rs2 ; REM[U] rdr, rs1, rs2 high half first, quotient first, “source register specifiers must be in the same order and rdh cannot be the same as rs1 or rs2” – that last clause because the fused unit still needs both sources intact when it delivers the second result. _reject_dependence already enforces exactly that (a.rd not in b.uses_regs), so rules.py gets it for free.
  • The clusters are listed in the spec’s sequence and the compiler already emits it (hi-first outnumbers lo-first 22:9 on musl-rv32, div-first is universal, and every measured occurrence satisfies the rdh-not-a-source rule). rules.py canonicalises, so both directions still pair; the canonical direction also gets the lighter dependence test, so keep it spec-ordered.
  • The gap is a toolchain one – a compiler that knew this pairing were available would emit the two halves adjacently and in order. Treat the low score as a measurement of clang and GCC, not of the frame.

    rsd-alu-pair

    Two in-place ALU updates, each rewriting its own source register.

0 0 p p p p p p p p 1 0 rs2a rsda alu rsda, rsda, rs2a rs2b rsdb alu rsdb, rsdb, rs2b 0 0 p p p p p p p p 1 0 imma rsda alu rsda, rsda, imma rs2b rsdb alu rsdb, rsdb, rs2b 0 0 p p p p p p p p 1 0 rs2a rsda alu rsda, rsda, rs2a immb rsdb alu rsdb, rsdb, immb 0 0 p p p p p p p p 1 0 imma rsda alu rsda, rsda, imma immb rsdb alu rsdb, rsdb, immb
slot Bslot A
slli ×2 addi li addiw andi slliw srli add sub mul or sh1add sh2add sh3add czero.nez
li ×8 0 2 3 4 5 6 7 8 9 10 11 12 13 14 15
addi ×4 128 130 131 132 133 134 135 136 137 138 139 140 141 142 143
slli 192 194 195 196 197 198 199 200 201 202 203 204 205 206 207
add 208 210 211 212 213 214 215 216 217 218 219 220 221 222 223
or 224 226 227 228 229 230 231 232 233 234 235 236 237 238 239
czero.eqz 240 242 243 244 245 246 247 248 249 250 251 252 253 254 255

Notes and observations (mostly AI)

  • The four register operands occupy the four 5-bit columns – 20 bits, the whole operand budget – so registers here are a FULL 5-bit field, x0..x31. An earlier draft anticipated cutting them to 4 bits; that is not needed and was never adopted.
  • The two slots declare DIFFERENT op sets, and deliberately. Range past the row’s five drawn bits is bought in opcode entries – an op declaring N bits occupies 2^(N-5) of them – so weight, not op count, is the budget, and one bit on li costs what four reg-reg opcodes cost. A weighs 16 as fifteen ops that are nearly all weight 1 (breadth); B weighs 16 as six ops, of which li at eight bits is 8 and addi at seven is 4 (depth). The block is 16 x 16 = 256, the same as the symmetric set it replaces.
  • The asymmetry is only purchasable because the pair is ORDER-FREE in 87.1% of the corpus residue: rsd-alu-pair packs two independent results, so unless one reads the other’s destination the scheduler may emit either orientation and only one need be encodable. The list scheduler already tries both (its tier-1 and tier-2 partner picks). See results/corpus/RSD-RESIDUE.md for the measurement and for the weighted optimisation that chose these two sets.

    prologue-pair

    Function prologue: reserve the stack frame and save the return address.

0 0 1 0 1 1 1 0 1 p 1 0 0 0 0 1 0 imm sp addi sp, -16*imm imm rs1b sp store rs1b, 16*imm-k(sp)
slot Bslot A
addi
sw 0
sd 1

Notes and observations (mostly AI)

  • rs1b is a drawn 5-bit field: ANY register may be the one stored at the top of the new frame. ra is the overwhelmingly common case but not a constraint – a leaf function that keeps its fp saves s0 there instead, and rules.py accepts it.

    epilogue-pair

    Function epilogue: release the stack frame and return.

0 0 1 0 1 1 1 0 0 p 1 0 0 0 0 1 0 imm sp addi sp, 16*imm rs1b jr rs1b
slot Bslot A
addi
jr 0
ret 1

Notes and observations (mostly AI)

  • The row draws only the target register: the rs2+rs1 columns carry the sp adjustment, so a jalr here has a ZERO offset by construction – there is no field for one – and rules.py rejects the nonzero-offset spelling.

    dual-setup-pair

    Two independent small moves or constants – argument marshalling.

1 0 0 0 1 p p p p p 1 0 rs2a rda mv rda, rs2a rs2b rdb mv/li rdb, rs2b 1 0 0 0 1 p p p p p 1 0 rs2a rda mv rda, rs2a immb rdb mv/li rdb, immb 1 0 0 0 1 p p p p p 1 0 imma rda li rda, imma immb rdb li rdb, immb 1 0 0 0 1 p p p p p 1 0 imma rda li rda, imma immb rdb li rdb, immb 1 0 0 0 1 p p p p p 1 0 imma li arda, imma immb rdb mv/li rdb, immb
slot Bslot A
mv
addi4spn ×2 8
mv 10
li 11
slot Bslot A
li ×2
addi4spn ×2 0
mv 4
li 6
slot Bslot A
addi4spn
addi4spn ×2 12

Notes and observations (mostly AI)

  • THE WIDE BAND IS ARGUMENT-DESTINED, measured (2026-08-05). With the width caps relaxed to ten bits, wide li destinations are argument registers 86-89% of the time on musl-gcc-rv32 + sqlite-rv64 (at 7 bits 404 arg vs 51 other, at 8 bits 938 vs 154) and 85%/65% on cpp-rv32 – the arg-call-pair effect, without the call. The band this replaces, 6 bits at any rd, is nearly vacant: keeping it alongside the 8-bit band (re-measured, full scheduler) buys +18 pairs over three corpora for a doubled block. Dropped.
  • Swept with the real scheduler against the 6-bit-any-rd baseline (musl-gcc-rv32 / sqlite-rv64 / cpp-rv32, corpus TOTALS so displacement is netted): +169 / +429 / +216 pairs. Part of the gain is displacement – rsd-alu-pair gives back up to 110, li-branch-chain up to 50 – which the totals already count. Re-measured 2026-08-07 on the merged operand-position baseline (post jalr-split and pcrel reworks, which absorbed part of the original gain): +144 / +249 on musl-gcc-rv32 / sqlite-rv64. Still positive at no block cost.
  • addi4spn deliberately does NOT get the split: its wide destinations are a coin flip on musl+sqlite (129 arg vs 135 other at 7 bits) and splitting it regressed musl-gcc while paying on cpp (+375/-71 relative) – a C++-marshalling bet, not a win. Its 6-bit band stays as it was.
  • The a0-a7 restriction is enforced by scheduler/rules.py (_ARG_REGS, shared with arg-call-pair); the yaml states it as the 3-bit destination part of the split rows, which is how arg-call-pair states it too. ONE wide variant on purpose: an intermediate draft carried the split in BOTH slots so per-slot pricing would see one declared width, which bought nothing – either slot alone covers every pair, because the frame is order-free and the encoder places the wide operand. The asymmetric ops spelling (li_8s in A, bare li in B) prices each slot as it actually is; rules.py stays slot-agnostic, accepting the wide op in either stream position.
  • A-SIDE, NOT B-SIDE, and the two are not interchangeable in shape even though they are in capture. The spare bits are in rs1: it is the destination port, rda is the only operand drawn there, and narrowing rda to a0-a7 frees two bits for imma without touching another column – which is arg-call-pair row 2 exactly. Slot B has nothing equivalent, since rd holds rdb alone and immb would have to straddle two columns. Each side also costs exactly one canonical-order inversion (A-side spells (wide li, mv) li-first; a B-side band would spell (addi4spn, wide li) spn-first), so that is not a discriminator.
  • PRICED 13 BY THE MODEL, ~19 BY HAND, in the same 32-block. opcode_codepoints scores each op against the slot’s WIDEST row, so slot B’s split rows (7-bit field) hide addi4spn’s sixth bit there, which in the full-rd rows still rides an opcode repeat – the same widest-row coarseness arg-call-pair already lives with; it also cannot see that B’s li needs its full-rd narrow row beside the split rows. A band-by-band hand count (B li: rd5 narrow + rd3 seven-bit + one repeat for the eighth) is ~19. Both are inside the block; the gap is a known model artifact, not spare room to spend.

    load-call-chain

    Load a function pointer and call through it (virtual dispatch).

0 0 1 0 1 1 1 1 p p 1 0 0 0 0 1 0 imma rs1a tmp load tmp, k*imma(rs1a) ra tmp jalr ra, 0(tmp) 0 0 1 0 1 1 1 1 p p 1 0 0 0 0 1 0 imma rs1a tmp load tmp, k*imma(rs1a) t1 tmp jalr t1, 0(tmp)
slot Bslot A
lw ld
jalr ra 0 1
jalr t1 2 3

Notes and observations (mostly AI)

  • rd: unused leaves the selecting sentinel to the enumerator, which allocates it from the reserved x0/x2 pool per (host, sentinel) – both patterns are reserved either way, so whichever has room carries this frame.
  • The link register is NOT drawn – both templates share one row and are told apart by the op-select, which is why the budget is 4 for two loads rather than 2. rules.py reads the permitted set from the b op list above (each op’s encode.rd), so a register that is not spelled here cannot be paired by the scheduler either.

    arg-call-pair

    Set up an argument, then call through a hard-coded base register.

1 0 1 0 1 1 p p p p 1 0 rs2a rda mv rda, rs2a immb[9:5] immb[4:0] ra jalr ra, 4*immb(ra) 1 0 1 0 1 1 p p p p 1 0 imma rda li rda, imma immb[9:5] immb[4:0] ra jalr ra, 4*immb(ra) 1 0 1 0 1 1 p p p p 1 0 imma rda addi4spn rda, 4*imma immb[9:5] immb[4:0] ra jalr ra, 4*immb(ra) 1 0 1 0 1 1 p p p p 1 0 imma rda sp load rda, k*imma(sp) immb[9:5] immb[4:0] ra jalr ra, 4*immb(ra) 1 0 1 0 1 1 p p p p 1 0 rs2a imma sp store rs2a, k*imma(sp) immb[9:5] immb[4:0] ra jalr ra, 4*immb(ra) 1 0 1 0 1 1 p p p p 1 0 imma rda addi rda, rda, imma immb[9:5] immb[4:0] t1 jr 4*immb(t1)
slot Bslot A
mv li addi4spn lw ld sw sd addi
jalr 0 1 2 3 4 5 6 7
jr t1 8 9 10 11 12 13 14 15

Notes and observations (mostly AI)

  • Row 1 holds mv, which needs only rs1a and rda; row 2 holds the three ops that want a wide immediate and an ARGUMENT destination, splitting the rs1 column three-two so imma reaches seven bits; row 3 holds the stores, whose source register needs all five bits and whose stack offsets are small. The call displacement rides funct5+rd in every row – the S-type immediate positions.
  • A 3-bit rda costs almost nothing here and buys two: li at rda3+imm5 catches 925 of the 933 that an unrestricted rda catches on cpp-rv32, because these are argument setups by construction. 3+7 beats 5+5 by 68% on cpp li and 18% on cpp addi4spn.
  • Load and store offsets scale by the ACCESS width, as c.lwsp and c.sdsp do: spill slots are aligned to the access, so k*imm costs nothing and reaches four or eight times further.
  • addi4spn scales by four, which is a real trade rather than a free one: 10.7% of cpp’s addi rd,sp are NOT 4-aligned, because C++ takes the address of byte- and short-sized stack temporaries, so scaling costs 13% of the cpp hits (6473 against 7456 for a raw 7-bit field) and buys 0..508 instead of 0..127. On rv64 the scaled form is the one the fit prefers outright.
  • The rd column carries the displacement in every row, so this frame neither hosts nor is hosted.

    setup-jump-pair

    Set up an argument or return value, then transfer control.

1 0 0 1 1 p p p p p 1 0 rs2a rda mv rda, rs2a rs1b jr/jalr rs1b 1 0 0 1 1 p p p p p 1 0 imma rs1a rda load rda, k*imma(rs1a) rs1b jr/jalr rs1b 1 0 0 1 1 p p p p p 1 0 imma rda load rda, k*imma(rs1a) rs1b jr/jalr rs1b 1 0 0 1 1 p p p p p 1 0 imma rda li rda, imma rs1b jr/jalr rs1b 1 0 0 1 1 p p p p p 1 0 rs2a rda mv rda, rs2a immb[9:5] immb[4:0] j 4*immb 1 0 0 1 1 p p p p p 1 0 rda rs1a load rda, 0(rs1a) immb[9:5] immb[4:0] j 4*immb 1 0 0 1 1 p p p p p 1 0 imma rda li rda, imma immb[9:5] immb[4:0] j 4*immb
slot Bslot A
mv lbu lw ld
ret 0 1 2 3
jalr ra 4 5 6 7
jr 8 9 10 11
slot Bslot A
li
ret 16
jalr ra 17
jr 18
slot Bslot A
mv lbu lw ld
j 20 21 22 23
slot Bslot A
li
j 24

Notes and observations (mostly AI)

  • j covers jal x0; a jal with a real destination is a call and is excluded from every jump frame.
  • Direct j (78-92% of this frame’s packets) takes rows 3-4: rs1b is dropped – a direct jump has no register operand – and immb takes funct5+rd, a 10-bit displacement in PACKET units at the branch-immediate positions every control frame shares. Packets are 4-byte aligned, so the low bit RVC must carry is dead and a displacement costs 0.54x its RVC bits. 10 bits covers 84.6% of direct j on sqlite and 97-98% on musl.
  • rules.py cannot range-check the displacement: corpus jump operands are unresolved labels, so a pairwise rule has nothing to test. The scheduled count includes the over-range tail (~15% on sqlite). See results/corpus/README.md.
  • On the direct-j rows a load has no offset field (offsets are zero in 98.2% of chained cases anyway) and li narrows to 5 bits.

    arith-jump-pair

    A last in-place computation, then a control transfer.

0 0 1 1 p p p p p p 1 0 0 0 0 1 0 rs2a rsda alu rsda, rsda, rs2a rs1b jr/jalr rs1b 0 0 1 1 p p p p p p 1 0 0 0 0 1 0 imma rsda alu rsda, rsda, imma rs1b jr/jalr rs1b 0 0 1 1 p p p p p p 1 0 0 0 0 1 0 imma rsda li rsda, imma rs1b jr/jalr rs1b
slot Bslot A
addi ×2 li ×2 addiw ×2 andi ×2 slli ×2 srli ×2 add and or xor
ret 0 2 4 6 8 10 12 13 14 15
jalr ra 16 18 20 22 24 26 28 29 30 31
jr 32 34 36 38 40 42 44 45 46 47
j 48 50 52 54 56 58 60 61 62 63

The results

Random test files, compiled with RVC and run through a scheduler to try to pick out viable instruction pairs gives these results:

corpus          insns   pairs  packet%  realRVC%   vsRVC  to parity
testcase0       21875    4185    80.9%     81.6%   99.1%      -167
godot           90171   15823    82.5%     76.3%  108.1%     +5545
cpp-rv32       418345   92667    77.8%     71.0%  109.7%    +28786
cpp-rv64       409200   87664    78.6%     71.2%  110.4%    +30274
musl-rv32      118990   27712    76.7%     74.9%  102.4%     +2159
musl-rv64      102010   22208    78.2%     72.7%  107.6%     +5608
sqlite-rv32    192688   45512    76.4%     72.1%  105.9%     +8258
sqlite-rv64    189602   42689    77.5%     72.1%  107.5%    +10266
---------------------------------------------------------------------
RV32 aggregate 751898  170076    77.4%     72.2%  107.2%    +39036
RV64 aggregate 790983  168384    78.7%     72.2%  109.1%    +51693
COMBINED      1542881  338460    78.1%     72.2%  108.1%    +90729

This is suboptimal because it’s not using a compiler which optimises for the instruction set I’ve created. It’s also suboptimal because I’m using code compiled for RVC, which has register pressure that does not apply here, and so it uses more instructions than strictly necessary (at least in Clang’s case – GCC finds excuses to use more instructions regardless). I have some other test cases but the tabulation got mucky, so let’s just run with the above for now.

Development process

I vibe-coded an instruction scheduler which scans a corpus of assembly and attempts to find and fuse pairable instructions according to rules describing what a legitimate instruction pair would look like; just to get a feel for what sorts of pairing rules I could introduce. It simply looks at register usage to determine when a result is no longer needed, and which instructions can move past which other instructions.

This seemed easier than writing my own compiler to optimise a made-up instruction set which I hadn’t designed yet, but it also has limitations. There’s no expectation for it to yield runnable code; merely to give a feel for how things would pack if the nits can be worked out.

Then I randomly threw rules at it to see what would stick.

And I pivoted to laying out the bit patterns by hand in order to prove that the budgets were being met.

Attempting to draw things around the standard RISC-V frames brought out the four-register-field pattern shown above, while sweeping up the remaining bits for an unregulated mix of frame selection and opcode selection.

Then, I did a bad thing: I ran optimisers against an irresponsibly small corpus to see what could be squeezed out.

Here’s the tooling, such as it is: CISC-V experiment (content-warning: unchecked AI output)

It’s no longer human-readable. Claude has taken things in its own direction. Much of what it does is flaky and unreliable, and most of the code is only there as a toolkit for “what if?” queries posed to the AI. I don’t really want to think about working with that code by hand.

But it’s sufficient to get a gist of whether the ideas make sense and how they map to real code.

Pairing rule selection

I drew inspiration from classic CISC operations, proposals for macro-op fusion, and things other architectures do. And just kind of threw them all in there and mused openly to Claude about how that looked to me and asked it for feedback.

Claude was not a reliable witness, and led me down many garden paths of faulty analyses and losses of comprehension. But it was an exploration I could pick up on a whim with my phone, so it won on convenience.

It feels like it’s come out with a lot of redundancies; though often the apparent redundancies are just redistributions of immediate sizes to suit different idioms.

Biclique optimisation

Some (many?) instructions pair naturally with only a limited set of other instructions. Attempting to pair a free choice of any operation with free choice of any other operation isn’t fruitful. By cutting the frames into different sections with different purposes (often emulating different CISC instructions) it becomes easy to minimise the combinations which are worth making space for. Possibly (TBD) at the cost of decode complexity.

Conversely, rules like rsd-alu-pair avoid relationships between slots and so encoding freedom of operation order wastes nearly one bit, and removing that freedom realises more opportunities for tuning.

All this optimisation risks over-fitting and adding complexity to the decoder, so it must be done thoughtfully (Narrator: it has not been done thoughtfully).

Immediate sizing

This is gnarly. You get 5 bits by default, aliasing with a register index. Five bits can’t fully specify a bit shift on a 64-bit target. Five bits is often not enough for a lot of things. And you have to decide whether it’s signed or not.

In a lot of cases the thing to do is sacrifice another register operand to get a 10-bit immediate. Implicit SP with a 10-bit offset means you can get to a lot of local variables.

Also, the value of the immediate in one slot will often be scaled by the instruction choice in the other slot (e.g., addi gets its immediate scaled by 4 if it’s paired with lw), and of course memory access also uses its own implicit scaling.

Otherwise, duplicate the opcode in the opcode list to extend the immediate range by one bit.

Trawling through code there are patterns of step changes in immediate requirements. The specific corpus is an obvious source of this effect, and some artefacts of the original immediate limits of the existing architecture, but other things appear to be a legitimate reflection of the way code tends to work. I just kind of guessed. Claude helped. It did lead to a lot of redundancy, but I didn’t have many better ideas, so immediate shaping became a big factor in the design choices.

Enumeration

For the sake of a quick POC I just enumerated all the frames according to their population counts rounded up to powers of two, and hoped I wouldn’t run out of space. There’s scope to do this more intelligently, signalling specific conditions relevant to the instruction decoder in specific bits of the enumeration, but I haven’t put the effort in at this stage.

Load/store pairings

I was initially very reluctant to merge load/store data operands into interdependent arrangements with arithmetic, because it leaves no space for scheduling around memory delays. Eventually I resigned myself to accepting it, though, because without that there’s going to be a lot less compression.

Also, it opened up opportunities to hint at things that could be left out of direct ALU paths. Things like indirect branching via memory; where branch prediction (when present) typically goes ahead with its decision without seeing the data, and the data is only there to cancel the prediction after the fact if it was inconsistent.

Chaining with a base-register operand was less painful. In particular filling the gap RISC-V leaves with rb+k*ri address generation which, it turns out, can be done via temporary register without needing to save the intermediate result.

Future work

decode complexity

A major problem right now is that the enumeration of frames and opcodes within frames doesn’t really attempt to match established conventions about how the decoder can resolve details quickly. It does (generally) put rs1 and rs2 for the first instruction in the same slots, so they can be prepared early, but there are other details one wants to know soonish which could be surfaced but have not been.

With a bit of massaging it appears to be possible to reduce the signature of each frame type down to a handful of bits in the opcode field, and squeeze a signature for the transform required to turn the slot-B parameters into a slot-A frame in another handful of bits. But I have not written (or vibed) such an allocator yet.

how to approach that

What we have right now is ten bits of “deal with it later”, and 20 bits corresponding to operand fields in fairly regular positions. 1024 codepoints, only about 3/4 populated after rounding each frame up to a power of two.

What I believe is needed is to collate every codepoint by its slot-A operand patterns (including immediate sizes and positions) and to pack these bits together as a convenient decoder index. I think Claude said this was doable in about three or four bits.

Then, without regard to those bits, we need to sort the codepoints by the slot B operands in the same way, and pack these together in the same way, but in some different bits, so that we can begin the re-format of slot B into a shape the normal instruction decoder can ingest. One would also want a clear signal for branches and jumps, for the instruction prefetch pipeline.

There’s no guarantee that will resolve into a sensible number of bits, but I asked Claude to try and it said it might get away with three more bits, but I haven’t verified.

And in the cases where a list of opcodes contains repetitions of an opcode to make up extra immediate bits, that enumeration should be swizzled to put the redundant opcode selector at bit 30 or 31 of the word, so immediate decode is relatively consistent. This seems like the least hard problem, but it should be done.

quality

Another problem is lack of thoughtful optimisation and, conversely, gross overfitting to my limited test corpus.

And, of course, the regularity needs to be improved. Balancing regularity and generality against compression. And then factoring the cost model of a realistic compiler in and then fitting more tightly to that, and then reasserting regularity and generality all over again. Around and around and around…

That said, this adventure promotes itself as being CISC-inspired, so being a random bucket of overlapping things that seemed like good ideas is pretty on-brand.

sundries

And a toolchain would be nice, too. And an implementation. And from those, feedback into what makes a better target, and back around the tuning loops again with that insight in mind.

AI disclosure statement

Yep.