perf(interp): eliminate per-opcode allocation overhead (stack, memory, MLOAD) - #29
Merged
Merged
Conversation
…, MLOAD) Three behaviour-preserving allocation cuts in the hot interpreter path that help every EVM transaction, not any specific contract: - Stack: reserve 64 slots up front instead of growing from empty — removes geometric realloc-copy on the hot PUSH/DUP/SWAP path. - Memory: reserve 1 KiB up front — removes realloc-copy as memory grows via set()/resize. - MLOAD: read the 32-byte word straight into an H256 via new Memory::load_h256, dropping the per-MLOAD `vec![0; 32]` heap allocation. Behaviour-preserving: - load_h256 is bit-identical to the prior `H256::from_slice(&get(offset,32))` (zero-pad past data / over-limit), verified by a new differential test across memory boundaries. - The two with_capacity hints are capacity-only — no semantic change, and the Borsh-serialized form is byte-identical (capacity is not serialized). Measured on a representative workload (Uniswap V3 single-hop swap, ~9 call frames / ~9,800 opcodes) executed in the Solana SVM: - compute units: 1,333,010 -> 1,299,202 (-33,808, -2.54%) - peak heap: 162,816 -> 139,264 (-23,552, -14.5%) - output bit-identical.
valiksinev
approved these changes
Jun 26, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three small, behaviour-preserving changes to the interpreter's hot path that cut executed-instruction count (CU) and heap allocation for every EVM transaction — not any specific contract. On a Solana SBF target, CU ≈ executed SBF instructions and the program heap is a bump allocator (allocations are never reclaimed within a tx), so per-opcode allocation churn is pure, measurable cost.
17 lines of code across 3 files, all in
evm-core.Finding
Profiling a representative contract execution attributed the interpreter cost almost entirely to 256-bit (32-byte) value movement: stack plumbing (PUSH/DUP/SWAP/POP) is the single largest category, memory ops next. Three sources of avoidable overhead sat in that path:
Vec<U256>was grown from empty → geometric realloc-copy as it fills on the hot PUSH path.Vec<u8>was grown from empty → realloc-copy as memory expands viaset().MLOADallocated a throwawayvec) just to build the returned word.Changes
core/src/stack.rsStack::new: reserve 64 slots up front (Vec::with_capacity(64))core/src/memory.rsMemory::new: reserve 1 KiB up front; addload_h256()— reads a 32-byte word directly into anH256, no heapVeccore/src/eval/misc.rsMLOADcallsload_h256instead ofH256::from_slice(&get(offset, 32))These are generic interpreter wins — they touch the opcodes that dominate any contract's execution (PUSH/DUP/SWAP, memory growth, and every
MLOAD), so the benefit applies across all workloads, not just the one used to measure it.Behaviour preservation
load_h256(offset)is bit-identical to the priorH256::from_slice(&get(offset, 32)[..])for every input — same out-of-range/over-limit guards, same zero-padding for partial-tail and past-data reads.with_capacityhints are capacity-only: no observable semantic change, and the Borsh-serialized form is byte-identical (capacity is never serialized), so checkpoint/resume state is unchanged.constfromStack::new/Memory::newhas no effect: their only caller is the non-constMachine::new; no const/static context depends on them.Tests conducted (interpreter-level, not workload-derived)
New
core/tests/load_h256.rs— a differential test assertingload_h256==H256::from_slice(&get(offset,32))directly againstMemory(no contract involved), across:offset + 32 > limitguard (includingusize::MAX-adjacent offsets).Results
cargo build(the crate-leveldeny(warnings)gate) is green;load_h256uses the same idioms as the existingget()it mirrors.Measured impact
Representative workload — a Uniswap V3 single-hop swap (~9 call frames, ~9,800 opcodes) — executed in the Solana SVM, baseline vs. this change:
A deep-recursion stress test (self-calling contract to depth 200) confirmed no new out-of-memory or terminal-failure behaviour versus baseline at any depth; the 1 KiB memory reserve was chosen specifically so per-frame heap matches baseline behaviour on deep call trees.
Risk
Low. The only value-producing change (
MLOAD→load_h256) is differential-tested bit-for-bit; the reserves are capacity-only with byte-identical serialization. Suggested downstream gate before deploy: the existing opcode integration suite (MLOAD coverage incl. partial-word-at-boundary) and an on-chain A/B.