Skip to content

perf(interp): eliminate per-opcode allocation overhead (stack, memory, MLOAD) - #29

Merged
anil-rome merged 1 commit into
masterfrom
perf-interp-alloc-elimination
Jun 26, 2026
Merged

perf(interp): eliminate per-opcode allocation overhead (stack, memory, MLOAD)#29
anil-rome merged 1 commit into
masterfrom
perf-interp-alloc-elimination

Conversation

@anil-rome

Copy link
Copy Markdown
Contributor

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:

  1. The stack Vec<U256> was grown from empty → geometric realloc-copy as it fills on the hot PUSH path.
  2. The memory Vec<u8> was grown from empty → realloc-copy as memory expands via set().
  3. Every MLOAD allocated a throwaway vec![0; 32] (inside get()) just to build the returned word.

Changes

File Change
core/src/stack.rs Stack::new: reserve 64 slots up front (Vec::with_capacity(64))
core/src/memory.rs Memory::new: reserve 1 KiB up front; add load_h256() — reads a 32-byte word directly into an H256, no heap Vec
core/src/eval/misc.rs MLOAD calls load_h256 instead of H256::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 prior H256::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.
  • The two with_capacity hints 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.
  • Dropping const from Stack::new / Memory::new has no effect: their only caller is the non-const Machine::new; no const/static context depends on them.

Tests conducted (interpreter-level, not workload-derived)

New core/tests/load_h256.rs — a differential test asserting load_h256 == H256::from_slice(&get(offset,32)) directly against Memory (no contract involved), across:

  • word-aligned, mid-word, and far offsets;
  • empty memory;
  • the written/zero boundary and partial-tail words (read window straddles end of written data → zero-pad);
  • the offset + 32 > limit guard (including usize::MAX-adjacent offsets).

Results

running 4 tests
test load_h256_matches_reference_across_boundaries ... ok
test load_h256_on_empty_memory_is_zero ... ok
test load_h256_partial_tail_word_is_zero_padded ... ok
test load_h256_respects_limit_guard ... ok
test result: ok. 4 passed; 0 failed

cargo build (the crate-level deny(warnings) gate) is green; load_h256 uses the same idioms as the existing get() 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:

Metric Baseline This PR Δ
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

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 (MLOADload_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.

…, 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.
@anil-rome
anil-rome requested a review from valiksinev June 26, 2026 01:13
@anil-rome
anil-rome merged commit 45205d4 into master Jun 26, 2026
7 checks passed
@skansal-rome skansal-rome mentioned this pull request Jun 29, 2026
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.

2 participants