Abstract
Not every computation needs 256 bits. Counters, offsets, lengths, and the inner loops of hashes and big-number arithmetic fit in 64, and the EVM makes them pay for 256 at every step. This proposal adds twenty-eight instructions that compute on the low 64 bits of their operands: the EVM64 instruction set of EIP-7937 — the same operations at the same costs — encoded as ordinary single-byte opcodes rather than behind a prefix byte. Each is the twin of an existing instruction, at the existing opcode plus 0xC0.
The instructions take no immediate data and enter no mode: they mix freely with the rest of the instruction set, and every result is an ordinary 256-bit word. Alone they make interpreted arithmetic modestly cheaper. Composed with static control flow they compound: code compiled to RISC-V under both executes 19 to 51 times fewer instructions than today's interpreted EVM, because a stack slot becomes a machine register only when its offset is static and its value fits the register.
Note: Significant assistance from AI is acknowledged, primarily for the reference implementation and its tests.
Motivation
Most of the values a contract computes with fit in 64 bits. On the 64-bit machines that run the EVM a 256-bit addition is four limbs and three carries, a multiplication ten partial products, a comparison up to four; the same work on a 64-bit value is one instruction. An interpreter hides most of the difference behind its dispatch. Compiled code does not, and neither does a zero-knowledge (ZK) virtual machine, where the prover pays per RISC-V instruction executed. These instructions let code that fits in 64 bits pay for 64.
Specification
The key words MUST and MUST NOT in this Specification are to be interpreted as described in RFC 2119 and RFC 8174.
The EVM's machine state includes a data stack of 256-bit words. These instructions leave it so. Each reads only the low 64 bits of its operands — the operand modulo 2^64 — and pushes a 64-bit result zero-extended to 256 bits: the high 192 bits of every result are zero. Signed instructions read the low 64 bits as a two's-complement integer. No mode is entered or left; the instructions may appear anywhere, in any mix with the rest of the instruction set.
The instructions
Each instruction is the twin of an existing one — the same operation on 64-bit operands — at the existing opcode plus 0xC0. In the table, a is the top of the data stack, b the item beneath it, and N the third, each read modulo 2^64; every result is reduced modulo 2^64; division truncates toward zero and division by zero yields zero.
| Opcode | Instruction | Pops | Pushes | Cost | Result |
|---|---|---|---|---|---|
0xC1 | ADD64 | 2 | 1 | verylow64 | a + b |
0xC2 | MUL64 | 2 | 1 | low64 | a × b |
0xC3 | SUB64 | 2 | 1 | verylow64 | a - b |
0xC4 | DIV64 | 2 | 1 | low64 | a / b |
0xC5 | SDIV64 | 2 | 1 | low64 | a / b, signed |
0xC6 | MOD64 | 2 | 1 | low64 | a mod b |
0xC7 | SMOD64 | 2 | 1 | low64 | a mod b, signed, with the sign of a |
0xC8 | ADDMOD64 | 3 | 1 | mid64 | (a + b) mod N, the sum not reduced; 0 if N = 0 |
0xC9 | MULMOD64 | 3 | 1 | mid64 | (a × b) mod N, the product not reduced; 0 if N = 0 |
0xCA | EXP64 | 2 | 1 | exp64 | a to the power b |
0xCB | SIGNEXTEND64 | 2 | 1 | low64 | b sign-extended from bit 8a + 7; b if a > 6 |
0xCC | CALLDATALOAD64 | 1 | 1 | verylow64 | CALLDATALOAD of a, modulo 2^64 |
0xCD | MLOAD64 | 1 | 1 | verylow64 + memory | MLOAD of a, modulo 2^64 |
0xCE | MSTORE64 | 2 | 0 | verylow64 + memory | MSTORE of b modulo 2^64 at a |
0xD0 | LT64 | 2 | 1 | verylow64 | 1 if a < b, else 0 |
0xD1 | GT64 | 2 | 1 | verylow64 | 1 if a > b, else 0 |
0xD2 | SLT64 | 2 | 1 | verylow64 | 1 if a < b, signed, else 0 |
0xD3 | SGT64 | 2 | 1 | verylow64 | 1 if a > b, signed, else 0 |
0xD4 | EQ64 | 2 | 1 | verylow64 | 1 if a = b, else 0 |
0xD5 | ISZERO64 | 1 | 1 | verylow64 | 1 if a = 0, else 0 |
0xD6 | AND64 | 2 | 1 | verylow64 | a and b, bitwise |
0xD7 | OR64 | 2 | 1 | verylow64 | a or b, bitwise |
0xD8 | XOR64 | 2 | 1 | verylow64 | a xor b, bitwise |
0xD9 | NOT64 | 1 | 1 | verylow64 | not a, bitwise |
0xDB | SHL64 | 2 | 1 | verylow64 | b shifted left a bits; 0 if a > 63 |
0xDC | SHR64 | 2 | 1 | verylow64 | b shifted right a bits; 0 if a > 63 |
0xDD | SAR64 | 2 | 1 | verylow64 | b shifted right a bits, signed, filling with the sign of b |
0xDE | CLZ64 | 1 | 1 | low64 | the number of leading zero bits of a; 64 if a = 0 |
0xC0, 0xCF, 0xDA, and 0xDF are left unassigned.
Equivalently, each instruction computes what its twin computes when every operand is replaced by its low 64 bits — zero-extended to 256 bits, or sign-extended for the signed instructions — and the result is reduced modulo 2^64 (less 192, for CLZ64). The reference implementation's tests check the table against the twins on exactly this property.
The three load and store instructions are their twins reduced modulo 2^64: the same 32-byte window at the same offset, the same memory expansion, the same bytes. MLOAD64 reads the low eight bytes of the word MLOAD would read, so it reads back what MSTORE stored of a 64-bit value; CALLDATALOAD64 reads the low eight bytes of a calldata word, so it reads an ABI-encoded 64-bit argument in place. MSTORE64 writes the full 32-byte word, its high 24 bytes zero.
Costs
Four new tiers, each a step below the tier of the twin: verylow64 is 2 where verylow is 3, low64 is 3 where low is 5, mid64 is 5 where mid is 8, and exp64 is 5 plus 25 per byte of the exponent — the exponent being b modulo 2^64, so at most eight bytes — where EXP is 10 plus 50. MLOAD64 and MSTORE64 pay memory expansion exactly as MLOAD and MSTORE do.
The discounts reflect the work: one machine word where the twin handles four, with no carries between limbs. Benchmarking will be needed to tell if the costs are well-balanced.
Analysis
None of the instructions takes immediate data, so JUMPDEST analysis is unchanged. Each has the fixed stack effect the table gives, so any validator or translator that works from an opcode table covers them by adding rows.
Rationale
Why single-byte opcodes rather than a prefix?
EIP-7937 specifies these operations behind a prefix: 0xC0 followed by the opcode of the 256-bit twin. That saves opcode space and costs a byte of code per instruction — and it makes the EVM's first multibyte opcode. JUMPDEST analysis must step over the prefix as it steps over PUSH data, with the oddity that C0 5B marks a valid destination yet halts when executed; a bad second byte needs its own halting rule; and every disassembler, validator, translator, and compiler must learn the one exception. Single-byte opcodes need none of this. They are rows in the opcode table, and everything that works from the table — the validators and translators behind the measurements below among them — covers them by adding rows. The rows 0xC1–0xCB and 0xD0–0xDE make the encoding mechanical, twin plus 0xC0, and the three memory instructions take spare slots beside the arithmetic. The price is opcode space: twenty-eight of the thirty-two values from 0xC0 to 0xDF, all unassigned today.
EIP-7937 also proposed reserving 0xC0–0xCF for further prefixes, such as a mode that halts on overflow. This proposal forecloses that row; a future mode would need another.
Why zero-extended results rather than a 64-bit mode?
EIP-7937 and its EOF alternative, EIP-7961, define a mode in which the high 192 bits of a 64-bit result are unobservable. Here they are simply zero. That is the specification; the implementation remains free. An interpreter can keep a typed stack, holding a 64-bit result as one machine word and zero-extending it only when a 256-bit instruction reads it, because the zeros are exactly what that representation reconstructs. A compiler can keep it in a register for the same reason. Nothing is lost against the mode, and nothing enters or leaves one.
Why these instructions?
The arithmetic row 0x01–0x0B and the comparison and bitwise row 0x10–0x1E, less BYTE, plus three loads and stores.
BYTE already indexes a big-endian word, so byte j of a 64-bit value is BYTE of 24 + j; a twin would save one gas. CLZ is in: it is the kind of thing 64-bit code does, and it counts to 64 rather than 256.
EIP-7937's JUMP and JUMPI twins are out. A destination already fits in 64 bits, and JUMPI tests for nonzero, which gives the same answer on a zero-extended value; the twins would save gas only. PUSH needs no twin: a push of up to eight bytes already pushes a 64-bit value with zero high bits. DUP, SWAP, and POP move words; there is nothing to narrow.
The loads and stores are the twins modulo 2^64 rather than eight-byte accesses. Eight-byte windows would pack arrays four times denser, but they would be the first instructions to touch memory at anything but the word, would need their own expansion rule, and would read ABI-encoded calldata 24 bytes off. The window chosen here reads back what existing code and existing compilers store, in place. Denser packing is a different proposal.
Why these costs?
They are EIP-7937's, carried over unchanged, and they are approximate: the saving is in the arithmetic, not in dispatch or gas accounting, and under an interpreter it is modest, as the measurements below show. The right costs will come from benchmarking.
Are there real-time performance gains?
EIP-7979 measured them in its assets, on two kernels at opposite ends of the workload — a loop of dependent multiplies and a recursive tree of calls — counting executed RISC-V instructions exactly, with the arithmetic and comparison instructions at the opcodes proposed here and the memory instructions at placeholders. Alone, under a bytecode interpreter, the 64-bit instructions buy 1.1x to 1.4x: dispatch dominates, and cheap arithmetic hardly shows. Composed with validated static control flow they buy 3.2x to 3.3x interpreting a register intermediate code, and 19x to 51x compiled ahead of time to RISC-V — more than the product of the two, because a stack slot can live in a machine register only when its offset is proven static and its value fits. Run in the Zisk zkVM, every program cost the prover its executed-instruction count plus a fixed startup, so the ratios are ratios of proving cost.
Storage dominates most transactions and lies outside these kernels, so such gains would be most visible where overhead can be minimal: precompiles, layer 2 chains, and EVM-compatible chains.
Backwards Compatibility
The opcodes 0xC0–0xDF are unassigned. Executing one today halts exceptionally and consumes all gas; code that does so was always broken, and after this change twenty-eight of them execute instead. Existing code's semantics are otherwise unchanged: no existing instruction changes, no instruction takes immediate data, and JUMPDEST analysis is the same.
This proposal is an alternative encoding of EIP-7937, not a complement: the two specify the same instructions twice. It does not preclude running the EVM in zero knowledge, nor foreclose EOF, RISC-V, or other changes.
Test Cases
Inputs are listed top of stack first; every output is a 256-bit word whose high 192 bits are zero. 0x10000000000000005 is 2^64 + 5: the high bits are garbage to be ignored.
| Op | Inputs, top first | Output |
|---|---|---|
ADD64 | 0xffffffffffffffff, 0x1 | 0x0 |
ADD64 | 0x10000000000000005, 0x7 | 0xc |
SUB64 | 0x0, 0x1 | 0xffffffffffffffff |
MUL64 | 0x100000000, 0x100000000 | 0x0 |
DIV64 | 0x10, 0x3 | 0x5 |
DIV64 | 0x1, 0x0 | 0x0 |
SDIV64 | 0xfffffffffffffff8, 0x2 | 0xfffffffffffffffc |
SDIV64 | 0x8000000000000000, 0xffffffffffffffff | 0x8000000000000000 |
MOD64 | 0x11, 0x5 | 0x2 |
SMOD64 | 0xfffffffffffffff9, 0x3 | 0xffffffffffffffff |
ADDMOD64 | 0xffffffffffffffff, 0xffffffffffffffff, 0x7 | 0x2 |
MULMOD64 | 0x8000000000000000, 0x8000000000000000, 0xffffffffffffffff | 0x4000000000000000 |
EXP64 | 0x2, 0x40 | 0x0 |
EXP64 | 0x3, 0x28 | 0xa8b8b452291fe821 |
SIGNEXTEND64 | 0x0, 0x80 | 0xffffffffffffff80 |
SIGNEXTEND64 | 0x1, 0x80 | 0x80 |
SIGNEXTEND64 | 0x7, 0x80 | 0x80 |
LT64 | 0x10000000000000005, 0x7 | 0x1 |
GT64 | 0x7, 0x10000000000000005 | 0x1 |
SLT64 | 0xffffffffffffffff, 0x0 | 0x1 |
SGT64 | 0x0, 0xffffffffffffffff | 0x1 |
EQ64 | 0x10000000000000005, 0x5 | 0x1 |
ISZERO64 | 0x10000000000000000 | 0x1 |
AND64 | 0x10000000000000005, 0xff | 0x5 |
OR64 | 0x10000000000000005, 0x2 | 0x7 |
XOR64 | 0xff, 0xf | 0xf0 |
NOT64 | 0x0 | 0xffffffffffffffff |
SHL64 | 0x3f, 0x1 | 0x8000000000000000 |
SHL64 | 0x40, 0x1 | 0x0 |
SHR64 | 0x1, 0x8000000000000000 | 0x4000000000000000 |
SAR64 | 0x1, 0x8000000000000000 | 0xc000000000000000 |
SAR64 | 0x64, 0x8000000000000000 | 0xffffffffffffffff |
CLZ64 | 0x1 | 0x3f |
CLZ64 | 0x10000000000000000 | 0x40 |
The ADDMOD64 and MULMOD64 rows distinguish the unreduced sum and product from wrapped ones: 2^65 - 2 is 2 modulo 7 where 2^64 - 2 is 0, and 2^126 is 2^62 modulo 2^64 - 1 where the wrapped product is 0. The SLT64 row is 0 under SLT, which reads 2^64 - 1 as positive.
EXP64 costs 5 with an exponent of 0, 30 with 255, and 55 with 256. With an exponent of 2^64 it costs 5 and yields 1: the exponent is read modulo 2^64.
For the loads and stores, let the calldata, or memory, hold the forty bytes 0x00 to 0x27. CALLDATALOAD64 of 0 yields 0x18191a1b1c1d1e1f, of 8 yields 0x2021222324252627, and of 16 yields 0, the window lying past the end. MLOAD64 of 0 yields 0x18191a1b1c1d1e1f. MSTORE64 of 0x10000000000000005 at 0 leaves the word at 0 reading 5 under MLOAD and MLOAD64 alike, its first 24 bytes zero; MSTORE64 at 8 extends memory to 40 bytes, as MSTORE at 8 would.
Reference Implementation
evm64.py gives every instruction as a pure function on integers, with the opcode table of costs and stack effects; test_evm64.py checks each against an independent implementation of its twin on the defining property, on edge values and on random words with garbage high bits, and runs the vectors above.
Against the Ethereum Execution Layer Specifications (EELS), following its conventions, the instructions are their twins with the operands masked and the gas tiers renamed. Four of the twenty-eight:
U64_MAX_VALUE = Uint(2**64 - 1)
U64_CEIL_VALUE = Uint(2**64)
class GasCosts:
...
VERY_LOW_64: Final[ExecutionGas] = ExecutionGas(Uint(2))
LOW_64: Final[ExecutionGas] = ExecutionGas(Uint(3))
MID_64: Final[ExecutionGas] = ExecutionGas(Uint(5))
OPCODE_EXP64_BASE: Final[ExecutionGas] = ExecutionGas(Uint(5))
OPCODE_EXP64_PER_BYTE: Final[ExecutionGas] = ExecutionGas(Uint(25))
OPCODE_ADD64: Final[ExecutionGas] = VERY_LOW_64
OPCODE_SDIV64: Final[ExecutionGas] = LOW_64
OPCODE_MLOAD64_BASE: Final[ExecutionGas] = VERY_LOW_64
OPCODE_MSTORE64_BASE: Final[ExecutionGas] = VERY_LOW_64
def low64(x: U256) -> Uint:
"""The low 64 bits of a word."""
return Uint(x) & U64_MAX_VALUE
def signed64(x: U256) -> int:
"""The low 64 bits of a word, as a two's-complement integer."""
v = int(low64(x))
return v - 2**64 if v >> 63 else v
def add64(evm: Evm) -> None:
# STACK
x = low64(pop(evm.stack))
y = low64(pop(evm.stack))
# GAS
charge_gas(evm, GasCosts.OPCODE_ADD64)
# OPERATION
push(evm.stack, U256((x + y) % U64_CEIL_VALUE))
# PROGRAM COUNTER
evm.pc += Uint(1)
def sdiv64(evm: Evm) -> None:
# STACK
dividend = signed64(pop(evm.stack))
divisor = signed64(pop(evm.stack))
# GAS
charge_gas(evm, GasCosts.OPCODE_SDIV64)
# OPERATION
if divisor == 0:
quotient = 0
elif dividend == -(2**63) and divisor == -1:
quotient = -(2**63)
else:
sign = get_sign(dividend * divisor)
quotient = sign * (abs(dividend) // abs(divisor))
push(evm.stack, U256(quotient % 2**64))
# PROGRAM COUNTER
evm.pc += Uint(1)
def mload64(evm: Evm) -> None:
# STACK
start_position = pop(evm.stack)
# GAS
extend_memory = calculate_gas_extend_memory(
evm.memory, [(start_position, U256(32))]
)
charge_gas(evm, GasCosts.OPCODE_MLOAD64_BASE + extend_memory.cost)
# OPERATION: the low eight bytes of the word MLOAD would read
evm.memory += b"\x00" * extend_memory.expand_by
value = U256.from_be_bytes(
memory_read_bytes(evm.memory, start_position + U256(24), U256(8))
)
push(evm.stack, value)
# PROGRAM COUNTER
evm.pc += Uint(1)
def mstore64(evm: Evm) -> None:
# STACK
start_position = pop(evm.stack)
value = U256(low64(pop(evm.stack))).to_be_bytes32()
# GAS
extend_memory = calculate_gas_extend_memory(
evm.memory, [(start_position, U256(len(value)))]
)
charge_gas(evm, GasCosts.OPCODE_MSTORE64_BASE + extend_memory.cost)
# OPERATION
evm.memory += b"\x00" * extend_memory.expand_by
memory_write(evm.memory, start_position, value)
# PROGRAM COUNTER
evm.pc += Uint(1)Security Considerations
The instructions add no state and no resource: each does bounded work on one machine word, pays memory expansion exactly as its twin does, and offers nothing new to exhaust.
The hazards are for compilers. EQ64, ISZERO64, and the comparisons judge the low 64 bits only: two words that differ above bit 63 are equal to EQ64, and a word that is zero below bit 63 is zero to ISZERO64. Arithmetic wraps at 2^64, not 2^256, so overflow checks must be written for the width in use. A compiler that emits these instructions for values it has not proven to fit in 64 bits emits wrong code; one that has proven it emits code that is cheaper and no less safe.
Copyright
Copyright and related rights waived via CC0.
