Skip to content

perf(levm): unchecked memory hot path - #7119

Merged
ilitteri merged 3 commits into
mainfrom
perf/levm-memory-hotpath
Sep 1, 2026
Merged

ilitteri merged 3 commits into
mainfrom
perf/levm-memory-hotpath

Conversation

@iovoid

@iovoid iovoid commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Motivation

Currently some of the bounds checks done could be elided, since we have guarantees the compiler is unaware of.

Description

Uses unchecked memory accesses to reduce such checks. Gives a 1.58% reduction of ZisK AIR cycles averaged across our test set, with the biggest improvement being on stress_memory_150M (-7.87%). No performance regressions on any of the test cases.

Squashed from the zkevm autoresearch branch perf/levm-memory-hotpath
(6 commits), deltas measured on the zisk guest benchmark:
- MSTORE rounds the new memory size once (real blocks -0.03..-0.05%)
- u256_to_usize: OR the high limbs into one branch (ecrecover -0.02%)
- MLOAD prerounded resize (stress_memory -1.74%)
- MLOAD set_top_unchecked (stress_memory -0.61%)
- unchecked buffer access, skipping the RefCell borrow flag
  (stress_memory -5.55%, total -2.17%)
- same on resize/MCOPY/store_zeros (total -0.10%)

Fixups on top of the source branch: rustfmt, drop the unnecessary `mut`
on the `buf_mut()` bindings, keep the `unsafe_code` expectation on the
`set_top_unchecked` declaration, and restore `Memory::len`'s doc comment
and `#[inline]` (the helpers had absorbed them).
@iovoid iovoid added the zkevm label Aug 7, 2026
@iovoid
iovoid requested a review from a team as a code owner August 7, 2026 20:17
@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rs → unsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot added L1 Ethereum client performance Block execution throughput and performance in general labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

This PR introduces well-justified performance optimizations for hot EVM paths (MLOAD/MSTORE). The unsafe code is appropriately documented, but there are soundness and correctness issues to address.

Critical Issues

1. Integer overflow in resize_prerounded (memory.rs:345)

Line 345: let real_new_memory_size = new_memory_size + self.current_base;

This uses unchecked addition. If current_base is large and new_memory_size is near usize::MAX, this overflows (wrapping in release, panicking in debug). While gas limits practically prevent huge allocations, defensive programming is required for consensus-critical code.

Fix: Use checked_add and return VMError::OutOfBounds (or equivalent) on overflow, matching the pattern used in load_word_prerounded at line 380.

2. Inconsistent overflow checking between resize and access

In load_word_prerounded (lines 377-387), you calculate true_offset with checked_add after calling `resize_prer


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. High: the new RefCell::as_ptr() fast path in memory.rs is unsound with cloned Memory instances and can turn what used to be a runtime borrow panic into UB. Memory is explicitly shared across call frames via Rc<RefCell<Vec<u8>>>, and this PR now routes reads/writes through raw &Vec / &mut Vec in buf/buf_mut (memory.rs, memory.rs). That becomes especially dangerous in public APIs like with_range (memory.rs) and lifecycle ops like truncate_to_base (memory.rs): one clone can hold a shared slice/reference while another clone mutates the same backing buffer, which RefCell previously rejected dynamically. The “single active frame” argument is not enough here because aliasing can still happen between parent/child clones or inside the with_range closure. I’d keep the RefCell guards for shared/public paths and, if needed, restrict any raw-pointer optimization to a very small leaf helper where non-aliasing is locally provable.

Notes

The MLOAD/MSTORE prerounded-size changes in stack_memory_storage_flow.rs look semantically equivalent for gas accounting and memory expansion, and the set_top_unchecked use at that call site appears correct.

I did not run the full test suite.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Greptile Summary

The PR optimizes LEVM’s MLOAD and MSTORE hot paths by reusing pre-rounded memory sizes and eliminating redundant stack, buffer, and conversion checks.

  • Adds unchecked backing-buffer helpers and routes memory operations through them.
  • Adds pre-rounded word load/store variants used by MLOAD and MSTORE.
  • Adds an unchecked stack-top replacement for stack-neutral MLOAD.
  • Consolidates high-limb checking in u256_to_usize.

Confidence Score: 5/5

The PR appears safe to merge; no concrete correctness, memory-safety, or gas-accounting failure remains on the changed paths.

The new unchecked accesses are preceded by equivalent bounds and stack validation, the pre-rounded size covers each 32-byte operation with checked arithmetic, and inspected buffer users do not overlap shared and mutable accesses.

Important Files Changed

Filename Overview
crates/vm/levm/src/memory.rs Adds unchecked RefCell-backed buffer access and pre-rounded word operations; inspected callers and arithmetic preserve the required ownership and bounds invariants.
crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs Reuses the checked rounded memory size for MLOAD/MSTORE gas and access while preserving opcode semantics.
crates/vm/levm/src/call_frame.rs Adds an unchecked stack-top setter whose MLOAD caller validates the top and performs no intervening depth mutation.
crates/vm/levm/src/utils.rs Rewrites the oversized-U256 check as a high-limb OR without changing accepted values.

Reviews (1): Last reviewed commit: "perf(levm): unchecked memory hot path" | Re-trigger Greptile

@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Lines of code report

Total lines added: 74
Total lines removed: 0
Total lines changed: 74

Detailed view
+------------------------------------------------------------------------+-------+------+
| File                                                                   | Lines | Diff |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/call_frame.rs                                | 488   | +7   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/memory.rs                                    | 355   | +57  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs | 392   | +10  |
+------------------------------------------------------------------------+-------+------+

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: perf(levm): unchecked memory hot path (PR 7119)

Overall this is a well-targeted, carefully-scoped perf change with good benchmarking data in the description. The gas/rounding math checks out under manual trace, and the unsafe blocks follow the existing style conventions in this file. A few things worth a closer look before merge.

1. Memory::buf() / buf_mut() bypass RefCell entirely — the soundness argument is real but fragile (crates/vm/levm/src/memory.rs:80-98)

fn buf(&self) -> &Vec<u8> {
    unsafe { &*self.buffer.as_ptr() }
}
fn buf_mut(&self) -> &mut Vec<u8> {
    unsafe { &mut *self.buffer.as_ptr() }
}

buf_mut takes &self and returns &mut Vec<u8> — the textbook case clippy::mut_from_ref exists to catch. Soundness here depends entirely on an invariant that lives only in doc comments: "only the current call frame is ever active, and every buf()/buf_mut() borrow is taken, used, and dropped within one non-reentrant function." I traced every call site (resize, resize_prerounded, store, store_data_zero_padded, copy_within, store_zeros, load_range*, load_word_prerounded) and each one does fetch-use-drop locally without recursing into another Memory method — so it holds today. Rc (not Arc) also rules out cross-thread aliasing at the type level, which helps.

The risk is that this invariant is no longer checked by anything (not even in debug builds — the RefCell used to panic on a real conflict; now it silently corrupts memory instead). next_memory() and clean_from_base() still go through the checked .borrow()/.borrow_mut(), so the file now mixes checked and unchecked access to the same shared buffer, and the checked calls no longer have any protective effect since the flag they read is never set by the raw-pointer path. A future change that stores a Ref/borrows across a nested call (e.g. a tracer hook reading a different live frame's memory, or a future refactor that makes a call handler hold buf_mut() across a recursive VM::execute) would fail silently instead of panicking.

Suggestion: move the invariant statement onto the Memory struct doc comment itself (it's currently only on the two private accessor methods), since that's the contract every future contributor touching call-frame/memory lifecycle code needs to honor, not just people who happen to open these two functions. Consider also gating a debug_assert!(self.buffer.try_borrow_mut().is_ok())-style check behind cfg(debug_assertions) in buf()/buf_mut() to keep some runtime detection in tests/fuzzing without paying for it in release.

2. resize_prerounded — unenforced precondition, broader visibility than needed (memory.rs:339-358)

pub fn resize_prerounded(&mut self, new_memory_size: usize) -> Result<(), VMError> {
    debug_assert_eq!(new_memory_size % WORD_SIZE_IN_BYTES_USIZE, 0);
    ...
    self.len = new_memory_size;

The word-alignment precondition is only checked via debug_assert_eq!, which compiles out in release. Memory::len backs the MSIZE opcode directly (stack_memory_storage_flow.rs:194), and per spec MSIZE must always report a multiple of 32 — so a caller that violates this precondition in a release build silently produces a consensus-breaking non-word-aligned memory size, with no error surfaced. Today the only two callers (store_word_prerounded, load_word_prerounded) pass an already-rounded value from calculate_memory_size, so this is currently safe — but resize_prerounded is pub and not used anywhere outside memory.rs. Recommend making it a private (non-pub) helper so its narrow precondition can't be reused incorrectly from an opcode handler down the line.

3. Inconsistent #[inline(always)] on the new hot-path functions (memory.rs:363-393)

store_word and load_word (the functions being replaced on the hot path) are #[inline(always)]. Their new _prerounded counterparts — the ones actually called from MLOAD/MSTORE now — have no inline attribute at all:

pub fn store_word_prerounded(...) -> Result<(), VMError> { ... }   // no #[inline] at all
pub fn load_word_prerounded(...) -> Result<U256, VMError> { ... }  // no #[inline] at all

Given the whole point of this PR is shaving cycles off this exact call path, this looks like an oversight rather than an intentional choice — worth adding #[inline(always)] to match resize_prerounded (#[inline]) and the sibling functions.

4. Correctness spot-checks — all consistent

  • MLOAD/MSTORE handlers: new_size = calculate_memory_size(offset, 32) (rounds offset+32 up to the next 32-byte multiple) is exactly the value resize() used to compute internally from the unrounded offset+32, so gas accounting and actual memory growth are unchanged — just computed once instead of twice.
  • set_top_unchecked (call_frame.rs:90-102) in MLOAD: the only fallible operations between the validating top_mut()? and set_top_unchecked are gas charging and memory access, neither of which touch stack.offset, so the "depth unchanged" safety precondition holds.
  • u256_to_usize (utils.rs): (val.0[1] | val.0[2] | val.0[3]) != 0 || val.0[0] > u32::MAX as u64 is logically equivalent to the original 4-way || chain — correct, and a legitimate micro-opt.

Minor

  • No new unit tests were added for resize_prerounded/load_word_prerounded/store_word_prerounded boundary cases (e.g. offset causing OutOfBounds, offset already at a word boundary). Likely covered by existing EF/hive test suites exercising MLOAD/MSTORE, but worth confirming CI includes memory-expansion edge cases for this path specifically given the new unchecked reads.

Nothing here blocks the PR outright — the design is consistent with the file's existing unsafe-for-perf conventions and the current call sites are sound by inspection — but Item 1 (the RefCell-bypassing accessors) is the one meriting real reviewer attention given how easy it would be for a future change to violate the invariant undetected.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 2.226 ± 0.019 2.192 2.254 1.07 ± 0.01
main_levm_BubbleSort 2.083 ± 0.014 2.062 2.102 1.00 ± 0.01
pr_revm_BubbleSort 2.204 ± 0.012 2.190 2.226 1.06 ± 0.01
pr_levm_BubbleSort 2.075 ± 0.021 2.053 2.120 1.00

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 716.6 ± 13.5 705.6 742.2 1.00 ± 0.02
main_levm_ERC20Approval 778.4 ± 9.3 767.0 797.6 1.09 ± 0.02
pr_revm_ERC20Approval 714.4 ± 10.4 699.7 736.2 1.00
pr_levm_ERC20Approval 788.2 ± 22.1 768.0 847.5 1.10 ± 0.03

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 99.0 ± 2.8 96.5 104.3 1.00
main_levm_ERC20Mint 127.2 ± 1.2 126.1 129.5 1.28 ± 0.04
pr_revm_ERC20Mint 100.7 ± 1.7 98.9 104.5 1.02 ± 0.03
pr_levm_ERC20Mint 128.3 ± 2.0 125.9 132.3 1.30 ± 0.04

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 175.8 ± 2.1 173.6 180.0 1.01 ± 0.02
main_levm_ERC20Transfer 206.6 ± 1.5 204.8 209.0 1.18 ± 0.01
pr_revm_ERC20Transfer 174.4 ± 1.6 171.7 177.1 1.00
pr_levm_ERC20Transfer 208.0 ± 9.2 202.8 233.5 1.19 ± 0.05

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 157.2 ± 1.7 155.0 160.6 1.00 ± 0.01
main_levm_Factorial 166.8 ± 1.3 165.3 168.9 1.07 ± 0.01
pr_revm_Factorial 156.5 ± 1.0 154.6 158.0 1.00
pr_levm_Factorial 166.9 ± 1.0 165.3 168.4 1.07 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.161 ± 0.028 1.119 1.194 1.00 ± 0.03
main_levm_FactorialRecursive 5.747 ± 0.048 5.680 5.843 4.96 ± 0.08
pr_revm_FactorialRecursive 1.158 ± 0.015 1.138 1.184 1.00
pr_levm_FactorialRecursive 5.779 ± 0.058 5.690 5.890 4.99 ± 0.08

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 135.4 ± 1.4 132.7 137.6 1.01 ± 0.02
main_levm_Fibonacci 140.0 ± 1.1 138.1 142.1 1.04 ± 0.02
pr_revm_Fibonacci 134.4 ± 2.1 132.4 140.0 1.00
pr_levm_Fibonacci 148.1 ± 25.0 137.5 218.8 1.10 ± 0.19

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 626.5 ± 19.6 607.6 670.3 1.30 ± 0.04
main_levm_FibonacciRecursive 482.2 ± 3.0 478.6 487.6 1.00
pr_revm_FibonacciRecursive 616.9 ± 5.8 609.2 628.9 1.28 ± 0.01
pr_levm_FibonacciRecursive 496.7 ± 37.9 478.9 603.8 1.03 ± 0.08

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 6.4 ± 0.1 6.3 6.6 1.00
main_levm_ManyHashes 7.8 ± 0.2 7.6 8.4 1.21 ± 0.04
pr_revm_ManyHashes 6.5 ± 0.2 6.3 6.9 1.01 ± 0.04
pr_levm_ManyHashes 7.9 ± 0.3 7.6 8.6 1.23 ± 0.05

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 201.8 ± 2.1 199.5 205.4 1.43 ± 0.02
main_levm_MstoreBench 141.6 ± 1.2 139.8 143.3 1.00
pr_revm_MstoreBench 204.2 ± 2.0 201.0 207.5 1.44 ± 0.02
pr_levm_MstoreBench 145.5 ± 2.8 142.6 151.0 1.03 ± 0.02

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 210.0 ± 2.7 207.0 213.3 1.30 ± 0.03
main_levm_Push 161.2 ± 3.4 158.2 167.9 1.00
pr_revm_Push 209.5 ± 1.9 206.4 212.8 1.30 ± 0.03
pr_levm_Push 163.0 ± 2.0 160.3 165.2 1.01 ± 0.02

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 119.0 ± 3.9 116.8 129.5 1.32 ± 0.05
main_levm_SstoreBench_no_opt 90.5 ± 3.1 88.5 98.7 1.01 ± 0.04
pr_revm_SstoreBench_no_opt 118.3 ± 1.3 116.9 120.6 1.32 ± 0.02
pr_levm_SstoreBench_no_opt 89.9 ± 1.0 88.8 91.8 1.00

@github-actions

github-actions Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Benchmark Block Execution Results Comparison Against Main

Command Mean [s] Min [s] Max [s] Relative
base 60.290 ± 0.301 59.782 60.677 1.00 ± 0.01
head 60.047 ± 0.170 59.809 60.356 1.00

@iovoid iovoid changed the title perf(l1): unchecked memory hot path perf(levm): unchecked memory hot path Aug 10, 2026
@github-actions github-actions Bot added levm Lambda EVM implementation and removed L1 Ethereum client labels Aug 10, 2026
@ilitteri ilitteri mentioned this pull request Aug 20, 2026
1 of 28 tasks
@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Aug 31, 2026
@ilitteri
ilitteri added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 007882a Sep 1, 2026
71 checks passed
@ilitteri
ilitteri deleted the perf/levm-memory-hotpath branch September 1, 2026 05:23
@github-project-automation github-project-automation Bot moved this from Todo to Done in ethrex_performance Sep 1, 2026
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

levm Lambda EVM implementation performance Block execution throughput and performance in general zkevm

Projects

Status: Done
Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants