Skip to content

perf(l1): cut the cost of cold contract-code access - #7095

Open
edg-l wants to merge 11 commits into
mainfrom
perf/cold-contract-code-access
Open

perf(l1): cut the cost of cold contract-code access#7095
edg-l wants to merge 11 commits into
mainfrom
perf/cold-contract-code-access

Conversation

@edg-l

@edg-l edg-l commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Motivation

ethrex is red on COLD_ACCOUNT_CODE_ACCESS in the EIP-8038 repricing fit: 7680 gas against that page's 3000 goal, second worst of six clients. Every other cold-access parameter is green.

The binding fixture is EEST test_account_access with AccountMode.EXISTING_CONTRACT_DIFF_MAX, where every target address holds a unique max-size runtime that is 24544 of 24576 bytes JUMPDEST. Splitting the measurement by account mode isolates the cost: a shared 24KB code runs as fast as a 1-byte code, so the code cache hits by code hash, and EXTCODEHASH/BALANCE stay fast, so the account path is fine (ethrex is the fastest of the six on NON_EXISTING). All of it is the cost of ingesting a newly seen bytecode, ~84 us per unique 24KB contract against ~17.6 for reth, ~18.5 besu, ~21.3 geth, ~25.3 nethermind.

Description

  1. Jump destinations are a bitmap, not an RLP list of u32 offsets. The list was ~4x the size of the code it described and was rebuilt on every code-cache miss. Per 24KB jumpdest-dense contract, the stored value goes from 97,906 B (3.98x code) to 27,654 B (1.13x), a load from 224 us to 0.48 us (17.4 us for a legacy value, rebuilt from the bytecode), and a JUMP validity check from 12.3 ns to 0.23 ns. No data rewrite is needed: the RLP item header tells a list from a byte string, so existing values still read. The store schema version is bumped with a no-op migration for the other direction, since an older binary cannot decode a bitmap and should warn rather than fail on the first code read. Building the bitmap is also cheaper than building the offset list, which matters for initcode analysis on every CREATE, 17.0 us against 49.8 us per 24KB of dense JUMPDESTs. The bits of a byte are accumulated in a register and stored once, so the scan loop carries neither a read-modify-write nor a bounds-check panic path.

  2. The code cache counts the bytecode it holds. Code::size() counted size_of::<Bytes>() instead of the heap allocation, so the byte budget excluded the only thing that makes an entry large. The budget goes from 64 MiB of jump tables to 256 MiB of bytecode, and re-inserting a cached hash no longer inflates the counter.

  3. Bloom filter for account_codes and account_code_metadata, 4KB blocks for the former. account_codes was the only exact-key point-lookup CF on the execution read path without a filter, and its 32KB blocks buy nothing when the SST value is just a blob reference. The metadata CF has no blob indirection, so it keeps 16KB; the bytecode CF also skips its last-level filter, whose lookups are almost always positive because the hash comes from an account that references it.

  4. EXTCODESIZE reads the code-length table. It was loading the full bytecode to return .len(), while ACCOUNT_CODE_METADATA was plumbed through every layer and reached only from a mempool check. Witnesses stay complete: a metadata read records the code hash, so the bytecode still lands in the witness and stateless validation recomputes the length from it.

Measured

Stateful suite 3f6a0898955dff4f, run 1785777240_7cd53c4f (2026-08-03T17:14Z) against the run before it, refit through the same pipeline the repricing site uses (benchmarkoor-fetch 0.3.1 and evm-gasfit 0.3.0 on the site's own fit.yaml, scoped to ethrex):

param before after
COLD_ACCOUNT_CODE_ACCESS 7736 (103.1 us) 4652 (62.0 us)
COLD_ACCOUNT_CODE_WRITE 10415 (138.9 us) 6355 (84.7 us)
COLD_ACCOUNT_NOCODE_ACCESS 1377 (18.4 us) 1389 (18.5 us)

Binding model n=11, R² 0.997, confidence intervals disjoint (98.8-105.7 us against 59.0-63.8 us). Refitting the pre-fix run gives 7736 where the site publishes 7680, so these are comparable to it. In raw wall time on the 300M DIFF_MAX fixture every code-loading opcode drops ~38% (EXTCODESIZE 68%, since (4) removes its bytecode read) while BALANCE and EXTCODEHASH, which never materialize the bytecode, sit flat at ~1940 and ~1950 ms across three runs.

The COLD_ACCOUNT_ACCESS goal is 3000 and needs both the CODE and NOCODE variants under it, so ethrex goes from 2.58x the goal to 1.55x and the goal stays unmet. Clearing it means 40.0 us per access at this fit's anchor, against the 62.0 measured here. What is left is the ~105 us blob read per unique contract, plus the 17.4 us legacy rebuild on this snapshot only. The blob read wants the BAL code prefetch issued as batched multi_gets rather than 22.9k point gets, which is a separate change.

Notes

  • (3) is a write-time option: it applies to newly flushed or compacted SSTs, and benchmarkoor gives every instance a copy of a pinned data directory, so it will show nothing there until that snapshot is regenerated. (1), (2) and (4) land on the existing snapshot.
  • (2) is a candidate to derive from the detected memory limit, like the block cache in fix(l1): size the RocksDB block cache from available memory #7093, once that lands.
  • The measured run predates a01c667, so it still carries the sparse-analysis regression that commit removes.
  • That run also carries fix(l1): size the RocksDB block cache from available memory #7093's block-cache sizing, which is not part of this PR and is what moves the storage parameters in the same refit. The account-path controls above are flat, so it does not account for the code-access delta.
  • Verified on glamsterdam-devnet-7 with the same commits: 11616 EEST blockchain tests pass, plus clippy and the workspace and storage suites.

edg-l added 3 commits August 3, 2026 17:32
A sorted `Arc<[u32]>` of JUMPDEST offsets, persisted next to the bytecode,
costs ~4x the code size for jumpdest-dense contracts (97,906 B of value for
24,576 B of code) and ~10 ns per entry to rebuild on every code-cache miss:
246 us for a max-size runtime that is almost all JUMPDESTs. A bitmap is
len/8 bytes at any density, decodes as a memcpy (0.5 us), and turns jump
validation into a bit test rather than a binary search (12.9 -> 0.37 ns).

Values written in the older form need no migration: the RLP item header
tells a list from a byte string, and for a list the bitmap is rebuilt from
the bytecode (26.6 us, still ~9x cheaper than decoding the list).

`Code::size()` now counts the bytecode allocation it excluded, so the cache
honors its byte budget instead of holding orders of magnitude more than it
accounts for; the budget goes from 64 MiB that bounded only the jump tables
to 256 MiB of actual bytecode. Re-inserting a cached hash no longer inflates
the accounted size.
`account_codes` was the only exact-key point-lookup CF on the execution read
path without a bloom filter, so a get had to read a data block per candidate
level to discover the key was absent; with blob files enabled the SST value is
only a blob reference, so its 32KB blocks bought nothing. `account_code_metadata`
fell into the default arm for the same reason. Both now match the trie-node and
flat-KV CFs: 4KB blocks, 10 bits per key.

Write-time option: applies to newly flushed/compacted SSTs, existing SSTs are
read as-is.
`get_code_metadata` loaded the whole bytecode to return `.len()`, so a size
query materialized up to 24KB out of the blob store; the size-only
`ACCOUNT_CODE_METADATA` table was plumbed through every layer but reached only
from a mempool check. Read it instead, falling back to code already loaded for
another reason.

Witness generation stays complete: a metadata read observes the bytecode, so
the logger records the code hash and the witness still carries the code, which
is what stateless validation recomputes the length from.
@edg-l
edg-l requested a review from a team as a code owner August 3, 2026 15:35
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Stateless (zkEVM) Amsterdam+ EF tests skipped

Where: tooling/ef_tests/blockchain/test_runner.rsparse_and_execute skips
fixtures with network >= Fork::Amsterdam when running with a stateless backend.
Affects make test-stateless (the vectors_zkevm/ run); make test-levm is
unaffected.

Why: The stateless run uses the tests-zkevm@v0.5.0 bundle, filled against
glamsterdam-devnet v6.1.0, which predeploys the EIP-8282 builder deposit/exit
contracts at the OLD addresses (0x0000884d…d9008282 / 0x000014574a…0f008282).
This client uses the devnet-7 addresses (0x0000bff4…300d8282 /
0x000064d6…800e8282, matching the live tests-glamsterdam-devnet@v7.2.0 bundle
used by make test-levm). Every Amsterdam+ block runs the end-of-block EIP-8282
builder system call; with the new addresses absent from the v0.5.0 bundle, each
stateless Amsterdam+ block fails with
SystemContractCallFailed("System contract: 0x0000…8282 has no code after deployment").
The skip is by fork rather than by test name, since cross-fork directories such as
for_amsterdam/prague/... still execute at the Amsterdam fork.

Removal: Delete the skip_stateless_amsterdam branch in parse_and_execute
once a tests-zkevm bundle filled with the devnet-7 builder predeploy addresses is
released and .fixtures_url_zkevm is bumped to it.

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

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a high-quality optimization PR. The switch from a sorted Vec<u32> to a bitmap representation for JUMPDEST validation is a significant performance win (O(1) bit-test vs O(log n) binary search, ~4x memory reduction for dense contracts), and the implementation correctly handles backward compatibility, cache accounting fixes, and stateless witness requirements.

Correctness & Security

  1. JUMPDEST Bitmap Logic (crates/common/types/account.rs:89-104): Correctly builds the bitmap by skipping PUSH immediates (0x60-0x7F). The bit manipulation 1 << (i % 8) assumes LSB-first ordering, which is consistent with the check in is_valid_jumpdest.
  2. Jump Validation (crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs:472): The replacement of the binary search with is_valid_jumpdest is correct. The bitmap only contains bits for opcodes reached by the linear scan, excluding 0x5b bytes inside PUSH immediates, matching consensus rules.
  3. Database Backward Compatibility (crates/storage/store.rs:4903-4916): The decode_jumpdests function correctly detects legacy RLP-encoded offset lists via is_list and recomputes the bitmap, ensuring existing databases migrate transparently without a heavy migration script.
  4. Witness Completeness (crates/vm/backends/levm/db.rs:83-91): Adding the code hash to code_accessed in get_code_metadata is required for EIP-8025 stateless validation, as the metadata (specifically code length) is derived from the code itself.

Performance & Memory

  1. Cache Size Accounting (crates/storage/store.rs:162-180): The fix to check for existing entries before incrementing cache_size prevents the drift bug where repeated reads of the same contract would permanently inflate the counter, causing premature evictions.
  2. Bytecode Size Inclusion (crates/common/types/account.rs:184-187): Updating Code::size() to include self.bytecode.len() ensures the 256 MiB cache budget (increased from 64 MiB) is actually honored. Previously, only the jump table size was counted, allowing the cache to grow unbounded.
  3. EXTCODESIZE Optimization (crates/vm/levm/src/db/gen_db.rs:495-507): Avoiding full bytecode materialization when only metadata (length) is needed significantly optimizes EXTCODESIZE calls.

Code Quality

  1. RocksDB Tuning (crates/storage/backend/rocksdb.rs:211-227): The 4KB block size with bloom filters for ACCOUNT_CODES is appropriate for the point-lookup access pattern (hash -> code). The shared configuration for ACCOUNT_CODE_METADATA (imported but not used in this diff) appears to be preparatory work for future metadata separation.
  2. Edge Case Handling (test/tests/common/jumpdest_bitmap_tests.rs:52-62): Tests verify that offsets past bytecode_len (including usize::MAX) correctly return false, preventing potential panics or out-of-bounds access.
  3. Empty Bitmap Optimization (crates/common/types/account.rs:114-118): Reusing EMPTY_JUMPDESTS for code without JUMPDEST opcodes avoids allocations for the common case of EOAs and simple contracts.

Minor Notes

  • Serde Breaking Change: The change from jump_targets: Arc<[u32]> to jumpdests: Arc<[u8]> in CodeSerde (crates/common/types/account.rs:200) changes the serialization format. If Code is serialized via serde for P2P wire format or RPC, this breaks compatibility.

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

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 200
Total lines removed: 23
Total lines changed: 223

Detailed view
+------------------------------------------------------------------------+-------+------+
| File                                                                   | Lines | Diff |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs                                 | 3338  | +9   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/common/types/account.rs                                  | 442   | +21  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/storage/backend/rocksdb.rs                               | 527   | +8   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/storage/migrations.rs                                    | 430   | +3   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/storage/store.rs                                         | 4972  | +148 |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/db.rs                                   | 139   | +6   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/db/gen_db.rs                                 | 796   | -8   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/db/mod.rs                                    | 257   | +5   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs | 382   | -15  |
+------------------------------------------------------------------------+-------+------+

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reduces cold contract-code access costs by replacing jump-target lists with compact bitmaps, correcting bytecode-cache accounting, tuning RocksDB code tables, and serving EXTCODESIZE from code metadata.

  • Persists JUMPDEST bitmaps while retaining compatibility with legacy offset-list records.
  • Accounts for bytecode allocations in the bounded LRU code cache and prevents duplicate accounting.
  • Adds smaller filtered RocksDB blocks for code and metadata lookups.
  • Routes EXTCODESIZE through code-length metadata while preserving witness bytecode tracking.

Confidence Score: 5/5

The PR appears safe to merge, with the changed storage formats, cache accounting, VM jump validation, and witness behavior preserving their required contracts.

Normal code construction and persistence produce matching jump-destination bitmaps, legacy database records are explicitly decoded, metadata lookup retains a correct fallback, and metadata-only execution reads still include bytecode in witnesses.

Important Files Changed

Filename Overview
crates/common/types/account.rs Replaces jump-target offsets with a compact bitmap, updates Code serialization and memory accounting, and preserves logical-bytecode padding.
crates/storage/store.rs Adds legacy-compatible bitmap decoding, corrects code-cache accounting and eviction, and uses the code-metadata table with a correct full-code fallback.
crates/storage/backend/rocksdb.rs Configures filtered 4 KiB blocks for account code and code-metadata point lookups.
crates/vm/backends/levm/db.rs Records metadata-only code observations so execution witnesses still include the corresponding bytecode.
crates/vm/levm/src/db/gen_db.rs Answers code-length queries from cached code or metadata instead of always loading full bytecode.
crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs Validates JUMP and JUMPI destinations through the new bitmap while retaining existing gas and tracing behavior.
test/tests/common/code_serde_tests.rs Updates Code serde coverage for bitmap preservation and bytecode padding.
test/tests/common/jumpdest_bitmap_tests.rs Covers valid destinations, PUSH immediates, bounds, empty maps, and bitmap sizing.
test/tests/common/mod.rs Registers the new jump-destination bitmap test module.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Opcode[EXTCODESIZE] --> GDB[GeneralizedDatabase]
  GDB --> Meta[Code metadata cache/table]
  Meta --> Length[Return code length]
  Meta -. legacy metadata miss .-> CodeStore[ACCOUNT_CODES]
  CodeStore --> Decode[Decode bytecode and bitmap]
  Decode --> Cache[Bounded code cache]
  Jump[JUMP or JUMPI] --> Bitmap[JUMPDEST bitmap check]
Loading

Reviews (1): Last reviewed commit: "perf(l1): answer EXTCODESIZE from the co..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

  1. High: jump() now trusts the bitmap alone, but Code::is_valid_jumpdest only tests a bit and does not verify offset < bytecode_len or that code[offset] == JUMPDEST. Combined with Store::get_account_code loading persisted bitmaps via decode_jumpdests and from_parts_unchecked, a malformed/stale bitmap can make LEVM accept an invalid jump. That is worse than before: the old path also checked the actual opcode byte. Because jump sets pc = target + 1, this can also violate the unchecked-read invariant in CallFrame::next_opcode if the bitmap marks offsets beyond real code. I would keep the bitmap as a fast filter, but still enforce bounds/opcode-byte correctness, or strictly validate the bitmap on load.

  2. Medium: decode_jumpdests no longer fully validates the second RLP item. It ignores trailing bytes from decode_rlp_item and accepts any byte-string length as a bitmap. The previous Vec<u32>::decode(targets) path rejected extra trailing data. This silently weakens corruption detection for ACCOUNT_CODES and feeds unchecked data into execution. At minimum, require the decoded item to consume all remaining bytes and enforce payload.len() == code.len().div_ceil(8) with zero high bits in the last byte.

I could not run the targeted tests here because the toolchain tries to write under read-only /home/runner/.rustup/tmp; the review above is from source inspection.


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

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

No cleanup needed, that was just a tool-output cache file. Here's the review.

Review: perf(l1): cut the cost of cold contract-code access (PR 7095)

Overall this is a well-scoped, well-tested performance PR. The bitmap representation, the cache-accounting fix, and the metadata-only EXTCODESIZE path are all correct, and the witness-completeness concern (does a metadata-only read still land the bytecode in the witness for stateless validation?) is explicitly addressed and covered — DatabaseLogger::get_code_metadata (crates/vm/backends/levm/db.rs:82-93) pushes into code_accessed, and blockchain.rs:1782-1799 still fetches full code for every hash in that list. I traced the encode/decode round trip (encode_code / decode_jumpdests in store.rs:4905-4924) and the legacy-format fallback (RLP list vs. byte-string header via decode_rlp_item) — both are correct, and the new test files cover the boundary cases (partial last bitmap byte, PUSH-immediate exclusion, legacy decode, cache eviction/dedup) well.

Findings

  1. Removed defense-in-depth on JUMP/JUMPI validation (crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs:585-608)
    The old check validated a jump target two ways: the bitmap and a direct read of dispatch_buf()[target] == JUMPDEST. The new code trusts is_valid_jumpdest alone. By construction the bitmap and bytecode can't diverge, so this isn't a correctness bug today — but it does remove a cheap cross-check that would have caught a corrupted/mismatched persisted bitmap (e.g. a future bug in decode_jumpdests, a botched migration, or memory corruption) before it could turn into a consensus-relevant wrong jump decision. Given this is exactly the kind of invariant that's cheap to keep and expensive to lose (a JUMP validity bug is a hard-fork-inducing bug class), consider keeping a debug-assertion of the opcode-byte check, or at least calling this out as an accepted trade-off in the PR description.

  2. Stale comment reference to jump_targets (crates/storage/store.rs:948)
    code_exists's doc comment still says "no jump_targets deserialization" — the field is now jumpdests. Pre-existing comment, not touched by this PR, but sits right next to the renamed type so worth a one-line fix while here.

Nits (non-blocking)

  • Code::size() (crates/common/types/account.rs:147-155) counts self.bytecode.len(), which includes the 33-byte BYTECODE_PADDING tail — a harmless ~33-byte overcount per entry for cache budgeting purposes, not worth changing.
  • The empty-bitmap sharing trick (EMPTY_JUMPDESTS) discards the freshly built all-zero bitmap Vec when any == false; correct and intentional per the comment, just flagging that this means Code::size() slightly undercounts jumpless contracts (again, immaterial to the 256 MiB budget).

Nothing else stood out — the RocksDB CF tuning (4KB blocks + bloom filter, blob files scoped to ACCOUNT_CODES only), the CodeCache::insert dedup/recency fix, and GeneralizedDatabase::get_code_metadata's "check self.codes first, then metadata store" fallback are all sound.


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

@github-actions

github-actions Bot commented Aug 3, 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 3.288 ± 0.063 3.233 3.428 1.20 ± 0.03
main_levm_BubbleSort 2.786 ± 0.042 2.739 2.848 1.02 ± 0.02
pr_revm_BubbleSort 3.278 ± 0.049 3.237 3.372 1.19 ± 0.02
pr_levm_BubbleSort 2.745 ± 0.028 2.713 2.787 1.00

Benchmark Results: ERC20Approval

Command Mean [s] Min [s] Max [s] Relative
main_revm_ERC20Approval 1.050 ± 0.010 1.041 1.069 1.02 ± 0.01
main_levm_ERC20Approval 1.058 ± 0.020 1.040 1.104 1.02 ± 0.02
pr_revm_ERC20Approval 1.060 ± 0.016 1.043 1.088 1.03 ± 0.02
pr_levm_ERC20Approval 1.033 ± 0.008 1.026 1.051 1.00

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 141.8 ± 1.6 140.0 144.7 1.00
main_levm_ERC20Mint 155.4 ± 5.0 152.2 168.9 1.10 ± 0.04
pr_revm_ERC20Mint 142.5 ± 1.0 140.8 143.5 1.00 ± 0.01
pr_levm_ERC20Mint 152.5 ± 0.9 151.8 154.8 1.08 ± 0.01

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 249.4 ± 3.4 246.5 256.6 1.00
main_levm_ERC20Transfer 264.2 ± 8.2 258.7 285.6 1.06 ± 0.04
pr_revm_ERC20Transfer 251.2 ± 3.7 247.8 258.6 1.01 ± 0.02
pr_levm_ERC20Transfer 259.7 ± 7.9 255.2 281.7 1.04 ± 0.03

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 246.5 ± 14.2 233.9 282.8 1.02 ± 0.06
main_levm_Factorial 246.6 ± 0.9 245.1 247.8 1.02 ± 0.02
pr_revm_Factorial 242.1 ± 4.8 234.4 248.4 1.00
pr_levm_Factorial 246.6 ± 1.9 245.2 251.7 1.02 ± 0.02

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.709 ± 0.062 1.622 1.780 1.00
main_levm_FactorialRecursive 10.417 ± 0.024 10.381 10.460 6.09 ± 0.22
pr_revm_FactorialRecursive 1.754 ± 0.042 1.692 1.816 1.03 ± 0.04
pr_levm_FactorialRecursive 10.421 ± 0.066 10.320 10.519 6.10 ± 0.23

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 242.5 ± 9.3 228.5 253.5 1.15 ± 0.04
main_levm_Fibonacci 210.8 ± 1.0 209.3 212.4 1.00
pr_revm_Fibonacci 240.4 ± 8.0 227.9 252.8 1.14 ± 0.04
pr_levm_Fibonacci 213.5 ± 4.8 210.1 225.4 1.01 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 908.2 ± 13.5 889.7 929.2 1.28 ± 0.03
main_levm_FibonacciRecursive 711.5 ± 9.2 702.0 728.3 1.00
pr_revm_FibonacciRecursive 908.1 ± 8.6 897.2 920.3 1.28 ± 0.02
pr_levm_FibonacciRecursive 712.7 ± 8.4 699.0 725.4 1.00 ± 0.02

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 9.2 ± 0.0 9.2 9.3 1.00 ± 0.01
main_levm_ManyHashes 10.5 ± 0.1 10.3 10.8 1.14 ± 0.02
pr_revm_ManyHashes 9.2 ± 0.1 9.0 9.3 1.00
pr_levm_ManyHashes 10.4 ± 0.3 10.2 11.1 1.14 ± 0.03

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 292.6 ± 3.6 287.4 297.9 1.42 ± 0.02
main_levm_MstoreBench 205.7 ± 2.5 203.1 211.2 1.00
pr_revm_MstoreBench 294.4 ± 5.7 288.8 307.9 1.43 ± 0.03
pr_levm_MstoreBench 208.2 ± 4.3 203.6 218.6 1.01 ± 0.02

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 331.7 ± 6.9 321.9 341.7 1.30 ± 0.03
main_levm_Push 256.4 ± 4.2 253.1 265.5 1.01 ± 0.02
pr_revm_Push 329.9 ± 6.4 320.7 339.8 1.29 ± 0.03
pr_levm_Push 255.0 ± 1.3 253.2 258.1 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 180.7 ± 7.9 177.3 203.2 1.57 ± 0.07
main_levm_SstoreBench_no_opt 115.3 ± 0.6 114.3 115.9 1.00
pr_revm_SstoreBench_no_opt 178.6 ± 1.2 176.7 180.1 1.55 ± 0.01
pr_levm_SstoreBench_no_opt 117.1 ± 3.6 114.4 124.4 1.02 ± 0.03

Indexing the bitmap put a bounds-check panic path in the scan loop, which
cost more than the scan itself: 24KB of jumpdest-free initcode took 55.4 us
to analyse against 11.2 us for the offset-list version it replaced, and
benchmarkoor's test_jumpdest_analysis[00] lost 69-78% throughput.

Accumulate a byte's bits in a register and store it once the scan leaves that
byte; the monotonically increasing index makes the single store safe. Against
the offset-list version, per 24KB of code: dense 49.8 -> 17.0 us,
jumpdest-free 11.1 -> 11.8 us, 1-in-32 12.5 -> 12.0 us, PUSH-heavy unchanged.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Benchmark Block Execution Results Comparison Against Main

Command Mean [s] Min [s] Max [s] Relative
base 79.576 ± 0.245 79.229 79.894 1.01 ± 0.01
head 78.990 ± 0.383 78.260 79.433 1.00

length: code_length,
let length = match self.codes.get(&code_hash) {
Some(code) => code.len() as u64,
None => self.store.get_code_metadata(code_hash)?.length,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the change that makes ACCOUNT_CODE_METADATA an execution-path read, and there's a pre-existing wrinkle behind it worth knowing about before merge.

Store::get_code_metadata (crates/storage/store.rs, the else branch on a table miss) auto-migrates: it loads the full code, derives the length, and fires a tokio::task::spawn to write the metadata back. That code is on main already, so it isn't yours — but until now it was, in your words, "reached only from a mempool check". This PR puts it under EXTCODESIZE.

Two things change character as a result:

Runtime context. tokio::task::spawn panics if no reactor is running on the calling thread. A mempool check is comfortably inside the async runtime; block execution is not always — anything reached via spawn_blocking or a sync execution path would panic on a metadata miss rather than degrade. Worth confirming every caller of EXTCODESIZE is on a runtime thread.

Miss volume. A miss is the steady state on any node whose codes predate the metadata table, and the burst is worst on exactly the shape this PR targets: the DIFF_MAX fixture is unique max-size contracts, so a block of them is one spawned write task per contract, concurrent with execution's own writes. Fine once migrated, potentially a large unbounded burst on the first blocks after upgrade.

Neither is a reason to hold the perf work, and the fallback is correct. But since it's newly hot, a bounded or synchronous backfill — or just confirming the panic case is unreachable — seems worth doing in this PR rather than discovering it on a mainnet upgrade. A one-off backfill at startup would also make the fallback cold again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, the panic case was reachable: execution runs on rayon workers, which have no runtime, so a metadata miss aborted the node. Fixed in 6dd6ca8, the spawn is gated on Handle::try_current() and skipping it just costs one code read next time.

Added a regression test that is deliberately a plain #[test] rather than #[tokio::test], since the absence of a runtime is the condition. Verified it fails without the guard.

That also bounds the burst you describe: on the DIFF_MAX shape the reads come from rayon workers, so no task is spawned at all. A startup backfill would still be worth having to make the fallback cold, but it is out of scope here.

edg-l added 2 commits August 4, 2026 14:22
EXTCODESIZE now resolves its length through Store::get_code_metadata, whose
miss path spawned the metadata backfill with tokio::task::spawn. Execution runs
on rayon workers, which are not in a runtime, and that spawn panics there, so a
hash with no metadata row would abort the node on the first EXTCODESIZE against
it. No migration backfills that column family, so any database written before it
existed is in exactly that state.

Spawn only when a runtime handle is reachable. Skipping the backfill costs one
code read the next time the hash is asked for.
Witness: code_accessed records one entry per read, and a contract read for both
its bytecode and its length is recorded twice, so the builder embedded the same
bytecode more than once. Dedup before embedding.

Code metadata: EXTCODESIZE made this the execution read path, where the cache was
an unbounded map behind a single mutex that serialized the parallel executor.
Bound it to a derived entry count, and answer from resident code in the caching
layer so a length read usually never reaches it.

Schema: bump the store version with a no-op migration. The value change needs no
rewrite forwards, but an older binary cannot decode a bitmap, and the bump makes
it warn instead of failing on the first code read.

RocksDB: the 4KB block size was justified by blob indirection, which the metadata
CF does not have; keep its 16KB and let the bytecode CF skip its last-level
filter, whose lookups are almost always positive.

Also make the legacy-decode test build its expectation from the offsets the old
format names rather than from the function under test.
@edg-l
edg-l requested a review from ElFantasma August 4, 2026 12:50
Comment on lines -51 to +57
pub jump_targets: Arc<[u32]>,
jumpdests: Arc<[u8]>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This changes the serialization of Code and therefore of AccountUpdate, breaking store_account_updates_by_block_number.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, thanks. Fixed in 9a4c1fd by dropping the jump destinations from the wire format entirely and recomputing them on deserialize: they are a pure function of the bytecode, so carrying them coupled AccountUpdates stored format to how they happen to be represented. The format is now hash plus bytecode, so a future representation change cannot break it again, and the payload loses a byte per eight bytes of code. Added a test asserting the field set.

Note this still does not make rows written by an older binary readable, and the rollup store has no schema version to gate on. Since those rows are consumed at batch commit the window is small, but tell me if you want a version guard instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would rather have a migration and not break things.

We could simply keep the field on CodeSerde and do the migration later if you want. For the encode path, transforming from a bitmap to a list should be easy.

@github-project-automation github-project-automation Bot moved this to In Progress in ethrex_l1 Aug 4, 2026
Code's serde form carried the jump destinations, so changing how they are
represented changed the wire format of everything embedding a Code. That includes
AccountUpdate, which the L2 rollup store persists with bincode and the committer
reads back to re-apply state, so rows written by an older binary would not decode.

They are a pure function of the bytecode, so drop them from the format and
recompute on deserialize. The format is now hash plus bytecode, which cannot be
broken again by a representation change, and the payload loses a byte per eight
bytes of code.
Comment thread crates/storage/store.rs
/// is cheaper than decoding the list.
fn decode_jumpdests(code: &[u8], encoded: &[u8]) -> Result<Arc<[u8]>, StoreError> {
let (is_list, payload, _) = decode_rlp_item(encoded)?;
if is_list {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No migration path

/// letting it fail on the first code read.
fn migrate_3_to_4(_backend: &dyn StorageBackend) -> Result<(), StoreError> {
Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since there is no migration, the "recompute everything" fallback will be used on every cold read. This is likely going to cause a big performance hit.

Comment on lines -51 to +57
pub jump_targets: Arc<[u32]>,
jumpdests: Arc<[u8]>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would rather have a migration and not break things.

We could simply keep the field on CodeSerde and do the migration later if you want. For the encode path, transforming from a bitmap to a list should be easy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client performance Block execution throughput and performance in general

Projects

Status: In Progress
Status: Todo

Development

Successfully merging this pull request may close these issues.

4 participants