Skip to content

[RISCV] Add XReviveVec: 256-bit integers as a machine type - #7

Draft
kvpanch wants to merge 7 commits into
release/22.xfrom
kvpanch/vec_ext_custom_inst
Draft

[RISCV] Add XReviveVec: 256-bit integers as a machine type#7
kvpanch wants to merge 7 commits into
release/22.xfrom
kvpanch/vec_ext_custom_inst

Conversation

@kvpanch

@kvpanch kvpanch commented Aug 14, 2026

Copy link
Copy Markdown

EVM words are a quarter of the IR revive emits, and RISC-V has no carry flag, so the generic expansion costs 56 bytes for one add i256 and 86 for icmp ult, with anything wider than 2*XLen passed by reference.

Add i256 to VRM2's type list, so at VLEN=128 a wide value is an LMUL=2 vector register pair, and give it one instruction per operation in the custom-2 opcode space: arithmetic, shifts, compares, memory, conversions, and the EVM-specific addmod/mulmod/exp/signextend. Wide arguments pass in vector registers. Copies and spills reuse the existing RVV paths.

Supporting changes: raise MaxDivRemBitWidthSupported so ExpandIRInsts leaves wide div/rem alone; guard the seteq/setne complex pattern against non-XLen operands; reserve FP up front, since wide spill slots appear after hasFP is decided; refuse to merge constant stores into a 256-bit one, which does not terminate; add IIT_I256 so intrinsics can take a 256-bit scalar.

Over revive's 15 benchmark contracts (103 modules): -30.04% code, -25.96% including the constant pool.

EVM words are a quarter of the IR revive emits, and RISC-V has no carry flag,
so the generic expansion costs 56 bytes for one `add i256` and 86 for
`icmp ult`, with anything wider than 2*XLen passed by reference.

Add i256 to VRM2's type list, so at VLEN=128 a wide value is an LMUL=2 vector
register pair, and give it one instruction per operation in the custom-2 opcode
space: arithmetic, shifts, compares, memory, conversions, and the EVM-specific
addmod/mulmod/exp/signextend. Wide arguments pass in vector registers. Copies
and spills reuse the existing RVV paths.

Supporting changes: raise MaxDivRemBitWidthSupported so ExpandIRInsts leaves
wide div/rem alone; guard the seteq/setne complex pattern against non-XLen
operands; reserve FP up front, since wide spill slots appear after hasFP is
decided; refuse to merge constant stores into a 256-bit one, which does not
terminate; add IIT_I256 so intrinsics can take a 256-bit scalar.

Over revive's 15 benchmark contracts (103 modules): -30.04% code, -25.96%
including the constant pool.
@kvpanch
kvpanch requested review from elle-j and xermicus August 14, 2026 11:41
@kvpanch
kvpanch marked this pull request as draft August 14, 2026 11:41
@xermicus

Copy link
Copy Markdown
Member

Quick thought about it, should we make the CSR_V registers callee-saved instead? Since they aren't used for arg passing, it may be beneficial

EVM words are 256 bits and RISC-V registers 64, so every wide operation becomes a
four-limb chain, each carry costing an `sltu` to produce and another to consume,
with anything wider than 2*XLen passed by reference.

Add i256 to VRM2, so at VLEN=128 a wide value is an LMUL=2 register pair, and give
it one instruction per operation in the custom-2 opcode space. The feature pins
VLEN, which also makes RVV spill slots fixed-size rather than sized in vlenb.

Over revive's 15 benchmark contracts (103 modules), .text:

  V5QuoteVerifier               564,202 ->   328,998   -41.7%
  XENCrypto                     469,344 ->   235,854   -49.8%
  FiatTokenV1                   356,650 ->   240,108   -32.7%
  SnapshotPCCSRouter            296,694 ->   176,628   -40.5%
  AutomataDcapAttestationFee    281,214 ->   170,824   -39.3%
  Festival                      181,228 ->   122,298   -32.5%
  P2PMarket                     135,070 ->    74,938   -44.5%
  T3rminalDriver                133,062 ->    72,018   -45.9%
  TetherToken                    50,432 ->    32,256   -36.0%
  ERC20Factory                   24,420 ->    16,030   -34.4%
  DaimoP256Verifier              21,388 ->     9,280   -56.6%
  ERC20                          21,044 ->    12,860   -38.9%
  Multicall3                     20,854 ->    13,584   -34.9%
  WETH9                          19,402 ->    14,200   -26.8%
  Sha256                          7,354 ->     6,220   -15.4%
  TOTAL                       2,582,358 -> 1,526,096   -40.9%

-36.5% including the constant pool. Opt-in and off by default.
@kvpanch

kvpanch commented Aug 17, 2026

Copy link
Copy Markdown
Author

Quick thought about it, should we make the CSR_V registers callee-saved instead? Since they aren't used for arg passing, it may be beneficial

Perhaps, we can change it too, but this will break RISC-V vector ABI

Comment on lines +1989 to +2006
let TargetPrefix = "riscv" in {
def int_riscv_revive_addmod
: DefaultAttrsIntrinsic<[llvm_i256_ty],
[llvm_i256_ty, llvm_i256_ty, llvm_i256_ty],
[IntrNoMem, IntrSpeculatable]>;
def int_riscv_revive_mulmod
: DefaultAttrsIntrinsic<[llvm_i256_ty],
[llvm_i256_ty, llvm_i256_ty, llvm_i256_ty],
[IntrNoMem, IntrSpeculatable]>;
def int_riscv_revive_exp
: DefaultAttrsIntrinsic<[llvm_i256_ty],
[llvm_i256_ty, llvm_i256_ty],
[IntrNoMem, IntrSpeculatable]>;
def int_riscv_revive_signextend
: DefaultAttrsIntrinsic<[llvm_i256_ty],
[llvm_i256_ty, llvm_i256_ty],
[IntrNoMem, IntrSpeculatable]>;
}

@elle-j elle-j Aug 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we note the operand order here as comments? The definitions carry types only, and since all are i256 in every position there is nothing to really distinguish them.

To match what revive's (replaced) stdlib.ll used, I'd suggest something like this (// Input: seems to be the convention in this file):

// Input: (a, b, modulus)
def int_riscv_revive_addmod : ...
// Input: (a, b, modulus)
def int_riscv_revive_mulmod : ...
// Input: (base, exponent)
def int_riscv_revive_exp : ...
// Input: (byte_index, value)
def int_riscv_revive_signextend : ...

This order is also what I note in revive PR 595.

Add i512 to VRM4 (LMUL=4) and a new i1024 machine type to VRM8 (LMUL=8), so the
same instruction set exists at three widths, generated from one multiclass. The
operation encoding has no room for a width -- the I- and S-type memory forms have
no spare field -- so each width takes its own custom opcode: i256 in custom-2,
i512 in custom-3, i1024 in custom-1.

Also adds wide min/max, and the cross-width truncation and zero-extension the
single-width version had no need for.

The corpus contains eight i512 operations, so it barely moves. On an artificial
benchmark of the same work at each width, .text:

  i256    3,404 ->  88   -97.4%
  i512   10,886 ->  88   -99.2%
  i1024  45,328 -> 118   -99.7%

The expansion is superlinear in limb count while the instruction sequence is flat.
Three crashes and a broken test found reviewing the last two commits:

  select i512/i1024   the width commit added the register classes but only
                      Select_VRM2, so SELECT_CC at the wider LMULs could not
                      be selected
  narrowing mul       emitted scalar MUL without a Zmmul guard, so
                      -mattr=+xrevivevec alone aborted in the asm printer
  VLEN diagnostic     tested whether the flag was passed rather than the
                      resolved bounds, so vscale_range(4,4) set VLEN=256
                      unchecked; it also rejected bits-min=0, which upstream
                      means "derive from Zvl"
  disasm test         wrapped llvm-mc in `not` for the no-extension run, but
                      decoding an unknown encoding warns and exits 0
The wider integers had an instruction set each, in custom-2, custom-3 and
custom-1, so three quarters of the custom encoding space went to a width axis --
including the space reserved for the carry extension. LMUL is a vtype field
precisely so instructions need no per-width encoding: one `revive.wadd` now
covers 256, 512 and 1024 bits, whichever `vsetivli` is in force, and the whole
extension fits in custom-2.

The encodings carry no width and take single vector registers. Per-width pseudos
carry the register class and LMUL, so the allocator sees the group and
RISCVInsertVSETVLI emits the configuration and elides the redundant ones; they
lower back to the one encoding through RISCVVPseudosTable. `BaseInstr` is named
rather than derived, because PseudoToVInst strips `_SE` and would otherwise map
REVIVE_W_SEQ to REVIVE_WQ. A wide load or store carries a relocation on its
offset, which no standard vector pseudo does, so the vector pseudo lowering
falls back to the ordinary operand lowering instead of asserting.

Re-establishing vtype after every call cost 4.6% of .text, so `vl` and `vtype`
are call-preserved in the E ABI. The register mask is what tells the insertion
pass the configuration is gone, so gating its `isCall` checks alone changed
nothing; the mask is used for preservation only, and neither register gets a
frame slot. The obligation is on the callee, and it holds for what revive emits,
where every wide operation is LMUL=2.

-39.06% code against no extension over the 103 benchmark modules, against
-39.94% for the three-opcode design: the width in vtype costs +4.65% and the
call-preserved vtype gives back 68% of it.
The width had come from vtype's LMUL, which meant a consumer of the object
could not tell how wide an instruction was without following the control flow
to find the configuration in force. funct7 had the room, so it goes there:
funct7[6:5] is the width and funct7[4:0] the operation, with the R-type shapes
sharing one funct3 the way the base ISA does. Loads keep a full twelve-bit
immediate, one funct3 each, so a constant pool entry is still reachable through
%lo; a store never needs one and gives up two bits instead.

Everything stays in custom-2, one funct3 is spare, and no vsetivli is emitted
at all -- which also removes the vtype machinery this had accumulated: the
per-block configuration, the call-preserved vl/vtype, and the register move
that read its width from vtype and so copied half a value.

Four places treat a vector copy as demanding vtype, not one; all are gated,
since revive.mvN carries the count it moves. Fixed-length vectors are off and
the oversized-equality combine bails, so nothing selects a standard vector
instruction PolkaVM does not implement.
…reserve it

LMUL is a vtype field precisely so instructions need no per-width encoding: one
revive.wadd again covers 128 through 1024 bits, whichever vsetivli is in force,
and the whole extension fits in custom-2 with custom-1 and custom-3 left free.

Three pieces of the funct7 work are orthogonal to how width is carried and stay:
register moves that name their own count, since a copy expands after the vsetvli
pass and can never read vtype; the fixes that stop RVV codegen leaking in; and
spills through the extension's own load and store.

The outliner may no longer lift instructions that read vtype. It runs after
vsetvli insertion, so an outlined body's width becomes a property of the call
site, which no consumer can recover from the instruction stream.

hasCallPreservedVType() no longer returns true unconditionally. Callers assumed
vtype survived a call while nothing restored it: the CalleeSavedRegs entry never
took effect, VTYPE being reserved and so never spilled. A caller at m2 calling a
callee that configured m4 resumed at m4, reloading 64 bytes where 32 were spilled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants