Skip to content

perf(l1): batch and stream the BAL code prefetch - #7099

Open
edg-l wants to merge 11 commits into
perf/cold-contract-code-accessfrom
perf/bal-code-prefetch-batch
Open

perf(l1): batch and stream the BAL code prefetch#7099
edg-l wants to merge 11 commits into
perf/cold-contract-code-accessfrom
perf/bal-code-prefetch-batch

Conversation

@edg-l

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

Copy link
Copy Markdown
Contributor

Stacked on #7095 (perf/cold-contract-code-access), which adds the ACCOUNT_CODES decode helpers this builds on. Retarget to main once that merges.

Motivation

On a block whose accounts hold code the executor has not seen, the BAL warmer does not get ahead of execution. Measured on the benchmarkoor stateful suite by parsing the client's own per-block log (warmer against exec), grouped by block gas:

block set n exec median warmer/exec median ratio >= 0.95 finished after exec
0-50 Mgas 3133 26.7 ms 0.268 26% 282
50-150 Mgas 401 580.8 ms 0.968 61% 101
150-250 Mgas 476 1101.2 ms 0.994 75% 164
250-400 Mgas 243 1638.7 ms 0.996 76% 92
exec >= 1000 ms 525 1576.4 ms 0.998 97% 256

Light blocks are fine at 0.268. Once a block carries real code-loading work the warmer consumes the whole execution window and lands at the wire or later: on the slowest blocks (~6100 ms exec) it finishes with 15-24 ms of slack, and on 256 of the 525 blocks over 1000 ms it finishes after execution. So the executor is racing the warmer for bytecode instead of being shielded by it, and warmer throughput is the constraint.

Two structural reasons, both in LEVM::warm_block_from_bal:

  1. A whole-block barrier. Code hashes come from account states, so Phase 1 read every access-list account before Phase 2 issued the first bytecode read. For the entire duration of Phase 1 the executor faults in code itself, however early the warmer finishes in aggregate.
  2. One point get per code hash. code_hashes.par_iter().for_each(|h| store.get_account_code(h)) is a get per hash, capped at core count in flight, where the accounts alongside it already collapse into a single batched read. There was no batched bytecode read on the Database trait at all.

Description

  • get_account_codes_batch on the VM Database trait, default impl looping get_account_code so non-rocksdb backends are unaffected, threaded through DatabaseLogger, DynVmDatabase, CachingDatabase and StoreVmDatabase.
  • Store::get_account_codes_batch resolves the block-data buffer and the code LRU first, then reads what is left. Cold reads here are latency-bound, so the read path is chosen by which mechanism gets more requests in flight for the batch at hand: a rayon fan-out of point gets reaches queue depth ~= core count for free, while contiguous shards of the sorted keys on blocking threads reach queue depth ~= shard count and share RocksDB blocks within a shard, but pay a spawn each. It shards only once the batch forms more 256-key shards than there are cores, so ordinary blocks keep the fan-out and bloated ones get the depth. Each shard decodes its own values, keeping the jumpdest bitmap rebuild parallel. The LRU is locked once per batch rather than twice per code.
  • warm_block_from_bal walks the access list in chunks instead of two whole-block phases, so code reads start after the first slice of addresses. The chunk is 256 * 64, the smallest that still saturates the shard path, so chunking cannot trade the executor's head start for shallower reads.
  • Code hashes now come out of the account read. CachingDatabase::get_account_states_batch returns the states it warms, which removes a get_account_state per access-list account (~115k on a bloated block) that existed only to recover hashes Phase 1 had already fetched.

Notes

  • The prefetch stays best-effort. The batched read is all-or-nothing, so on error the warmer falls back to per-hash reads rather than leaving a whole chunk cold because one code hash in an access list is absent from the database.
  • Witnesses are unaffected: DatabaseLogger records every hash in the batch before delegating, matching the per-hash path, and the warmer does not run when witness collection is on.
  • prefetch_accounts keeps its own implementation rather than delegating to the states batch, because its other caller passes a whole write set and would pay for a result vector it discards.
  • perf(l1): sharded + concurrent BAL prefetch warming (flat-KV, accounts, trie-nodes) #6980 hoists these shard-tuning constants into a shared shard_count helper and adds the storage-side batch this mirrors. The constants are local here to keep this independent of that PR; worth folding into the helper whenever the two meet.
  • Not yet measured. The bottleneck above is measured, and the redundant per-account lookups and lock traffic this removes are countable, but the size of the win depends on how well MultiGet batches blob-backed values, which only a run on the bloated fixtures will show. warmer/exec on heavy blocks is the direct readout: if that median does not drop below 1.0 with real slack, the batching was not the constraint.

How was it tested?

test/tests/storage/account_code_batch_tests.rs checks the batched read against the per-hash read the executor uses, since the prefetch warms a cache execution then trusts: same bytecode and same jumpdest bitmap per hash, None for absent hashes, results aligned to the caller's order rather than the internal read order, and every position of a hash repeated within one batch. Sized from available_parallelism so the parity check crosses into the sharded path, with a small-batch case covering the fan-out.

Plus the workspace suites and -D warnings clippy.

@edg-l
edg-l requested a review from a team as a code owner August 4, 2026 08:57
@github-actions

github-actions Bot commented Aug 4, 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 4, 2026
@edg-l
edg-l force-pushed the perf/bal-code-prefetch-batch branch from 5005218 to 39bcc7e Compare August 4, 2026 08:57
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall this is a high-quality optimization PR with sophisticated I/O scheduling. The sharding strategy for RocksDB reads is well-reasoned and the chunking in the prefetcher correctly overlaps account state and code fetching.

No critical issues found. Below are minor observations and suggestions:

crates/storage/store.rs

Line 1060-1070 (Parallel fan-out inefficiency): The fan-out path calls decode_shard with single-element slices (std::slice::from_ref), which allocates a Vec<&[u8]> per hash and calls multi_get for individual keys. For the fan-out path, consider using a simple point get instead to avoid the allocation overhead:

// Instead of decode_shard(rv, std::slice::from_ref(hash))
rv.get(ACCOUNT_CODES, hash.as_bytes()) // then decode

Line 1047 (Thread-safety of read view): The code assumes dyn StorageReadView is Sync (used across threads in both rayon and std::thread::scope). Verify this bound is explicitly declared on the trait or implementation to prevent future unsoundness if the backend changes.

crates/vm/backends/levm/mod.rs

Line 2873-2878 (Silent fallback): The fallback to per-hash reads on batch failure silently ignores errors from individual gets. Consider logging these at debug or trace level to help diagnose storage issues in production:

if let Err(e) = store.get_account_code(*hash) {
    tracing::debug!(%hash, error = %e, "individual code fetch failed in warmer fallback");
}

crates/vm/levm/src/db/mod.rs

Line 281 (Unnecessary HashMap value): The seen map uses () as a value. Consider FxHashSet instead for clarity:

let mut seen: FxHashSet<H256> = FxHashSet::default();
// ...
.filter(|h| !cache.contains_key(h) && seen.insert(*h))

crates/blockchain/vm.rs

Line 319 (Length assumption): The zip assumes read returns exactly to_read.len() elements. Document this invariant in the Store method contract or assert it explicitly to prevent misalignment if the store implementation changes.

Tests

The test coverage in account_code_batch_tests.rs is excellent, covering sharding thresholds, duplicate handling, and order preservation. Consider adding a test for the empty batch case (code_hashes.is_empty()) to ensure it doesn't panic in the sorting/sharding logic.


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

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

This PR streams BAL account/code warming in chunks and adds batched account-code retrieval across the VM and storage layers.

  • Adds cache-aware, deduplicated code batching with parallel point reads or sharded multi_get reads.
  • Threads the batch API through VM database adapters and caching.
  • Adds parity tests for ordering, duplicates, missing hashes, bytecode, and jump-destination data.
  • Leaves the new changelog reference with an unresolved pull-request placeholder.

Confidence Score: 4/5

The PR appears safe to merge after the non-blocking changelog link placeholder is replaced with PR #7099.

The production batching and warming paths preserve ordering, cache behavior, and best-effort fallback semantics; the only accepted issue is an invalid changelog reference.

Files Needing Attention: CHANGELOG.md

Important Files Changed

Filename Overview
crates/storage/store.rs Adds ordered, deduplicated batched code reads using the buffer, LRU, and parallel backend reads; no concrete correctness defect was found.
crates/vm/levm/src/db/mod.rs Adds caching batch methods that preserve the current database adapters' ordered one-result-per-input contract.
crates/vm/backends/levm/mod.rs Reworks BAL warming into account chunks and falls back to individual code reads when a batch fails.
crates/blockchain/vm.rs Bridges Store's optional-code batch results into the VM's all-present code contract while handling empty-code hashes.
test/tests/storage/account_code_batch_tests.rs Covers batch parity, ordering, duplicate hashes, absent hashes, and both batching branches.
CHANGELOG.md Documents the optimization but retains a broken PRNUM pull-request placeholder.

Sequence Diagram

sequenceDiagram
  participant Pipeline as Block pipeline
  participant Warmer as BAL warmer
  participant Cache as CachingDatabase
  participant Store
  participant Executor
  Pipeline->>Warmer: Start with BAL accounts
  loop Each account chunk
    Warmer->>Cache: get_account_states_batch(addresses)
    Cache->>Store: Fetch uncached account states
    Store-->>Cache: Ordered states
    Warmer->>Cache: get_account_codes_batch(distinct hashes)
    Cache->>Store: Fetch uncached code batch
    Store-->>Cache: Ordered bytecodes
  end
  Executor->>Cache: Read warmed accounts and code
Loading
Prompt To Fix All With AI
### Issue 1
CHANGELOG.md:18
**Unresolved pull-request placeholder**

The new changelog entry displays `#PRNUM` and links to a nonexistent `/pull/PRNUM` page instead of identifying PR #7099.

```suggestion
- Batch and stream the BAL contract-code prefetch: warm accounts and their code in chunks instead of reading every access-list account before the first bytecode, take code hashes from the account read rather than a second lookup per account, and add a batched bytecode read that resolves the buffer and code cache first, then either fans out parallel point gets or shards the remainder across concurrent `multi_get`s, whichever reaches the greater read queue depth for the batch size [#7099](https://github.com/lambdaclass/ethrex/pull/7099)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: 5005218 | Re-trigger Greptile

Comment thread CHANGELOG.md Outdated
### 2026-08-04

- Cut the cost of a cold contract-code access: store jump destinations as a 1-bit-per-byte bitmap instead of a persisted RLP list of `u32` offsets, count the bytecode in the code cache's byte budget, answer `EXTCODESIZE` from the code-length table instead of materializing the bytecode, and give the account-code column families a bloom filter and 4KB data blocks. `COLD_ACCOUNT_CODE_ACCESS` drops from 7736 to 4652 gas in the EIP-8038 repricing fit, and `COLD_ACCOUNT_CODE_WRITE` from 10415 to 6355 [#7095](https://github.com/lambdaclass/ethrex/pull/7095)
- Batch and stream the BAL contract-code prefetch: warm accounts and their code in chunks instead of reading every access-list account before the first bytecode, take code hashes from the account read rather than a second lookup per account, and add a batched bytecode read that resolves the buffer and code cache first, then either fans out parallel point gets or shards the remainder across concurrent `multi_get`s, whichever reaches the greater read queue depth for the batch size [#PRNUM](https://github.com/lambdaclass/ethrex/pull/PRNUM)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unresolved pull-request placeholder

The new changelog entry displays #PRNUM and links to a nonexistent /pull/PRNUM page instead of identifying PR #7099.

Suggested change
- Batch and stream the BAL contract-code prefetch: warm accounts and their code in chunks instead of reading every access-list account before the first bytecode, take code hashes from the account read rather than a second lookup per account, and add a batched bytecode read that resolves the buffer and code cache first, then either fans out parallel point gets or shards the remainder across concurrent `multi_get`s, whichever reaches the greater read queue depth for the batch size [#PRNUM](https://github.com/lambdaclass/ethrex/pull/PRNUM)
- Batch and stream the BAL contract-code prefetch: warm accounts and their code in chunks instead of reading every access-list account before the first bytecode, take code hashes from the account read rather than a second lookup per account, and add a batched bytecode read that resolves the buffer and code cache first, then either fans out parallel point gets or shards the remainder across concurrent `multi_get`s, whichever reaches the greater read queue depth for the batch size [#7099](https://github.com/lambdaclass/ethrex/pull/7099)
Prompt To Fix With AI
This is a comment left during a code review.
Path: CHANGELOG.md
Line: 18

Comment:
**Unresolved pull-request placeholder**

The new changelog entry displays `#PRNUM` and links to a nonexistent `/pull/PRNUM` page instead of identifying PR #7099.

```suggestion
- Batch and stream the BAL contract-code prefetch: warm accounts and their code in chunks instead of reading every access-list account before the first bytecode, take code hashes from the account read rather than a second lookup per account, and add a batched bytecode read that resolves the buffer and code cache first, then either fans out parallel point gets or shards the remainder across concurrent `multi_get`s, whichever reaches the greater read queue depth for the batch size [#7099](https://github.com/lambdaclass/ethrex/pull/7099)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Already fixed. The placeholder was filled in once the PR number existed; the entry links #7099.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

No correctness, security, or consensus findings in this diff.

The store-side batch read in store.rs preserves caller order, deduplicates backend reads, and rebuilds Code through the same decode path as the single-get implementation. The VM-facing adapter in vm.rs also keeps EMPTY_KECCAK_HASH behavior aligned with the existing per-hash API, and the BAL warmer’s fallback in mod.rs avoids turning a missing code blob into a fatal prefetch error.

Residual risk: I could not run cargo test -p test account_code_batch_tests in this sandbox because cargo/rustup need writeable home dirs and external fetches, which are blocked here. The added tests in account_code_batch_tests.rs cover the important storage-path cases well; the only remaining gap I see is the lack of a direct integration test for the chunked BAL warmer path in mod.rs.


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

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 386
Total lines removed: 0
Total lines changed: 386

Detailed view
+---------------------------------------+-------+------+
| File                                  | Lines | Diff |
+---------------------------------------+-------+------+
| ethrex/crates/blockchain/vm.rs        | 361   | +36  |
+---------------------------------------+-------+------+
| ethrex/crates/storage/store.rs        | 5078  | +106 |
+---------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/db.rs  | 167   | +28  |
+---------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs | 3861  | +144 |
+---------------------------------------+-------+------+
| ethrex/crates/vm/db.rs                | 42    | +9   |
+---------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/db/mod.rs   | 320   | +63  |
+---------------------------------------+-------+------+

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 7099 — Batch and stream the BAL code prefetch

Overall design is sound and well-documented: chunking the warmer to interleave account-state and code prefetch instead of using two whole-block phases is a real fix for the head-of-line blocking described in the PR body, and the batched RocksDB read (buffer/LRU-first, then fan-out-vs-shard multi_get) is a reasonable way to raise read concurrency without regressing small-block latency. The new test/tests/storage/account_code_batch_tests.rs gives good parity coverage across the fan-out and sharded paths, including duplicate-hash and absent-hash cases.

Findings, ranked by relevance:

  1. Fallback path lost its parallelismcrates/vm/backends/levm/mod.rs:2872-2876

    if store.get_account_codes_batch(&code_hashes).is_err() {
        for hash in &code_hashes {
            let _ = store.get_account_code(*hash);
        }
    }

    The code this replaced fetched codes with code_hashes.par_iter().for_each(...). The batch read is all-or-nothing (any single absent code hash — plausible with a stale/short-lived BAL — errors the whole batch), so on that error path the warmer now re-fetches every code hash in the chunk sequentially, one get_account_code at a time, for up to WARM_CHUNK_ACCOUNTS (16384) accounts' worth of distinct hashes. Given this PR's whole point is to keep the warmer ahead of the executor, silently falling back to a strictly serial loop on any single miss is a real regression for exactly the "block carries real code" case the PR targets. It's also evidenced by the accompanying import change removing IntoParallelRefIterator (no more par_iter on this path). Consider keeping the fallback parallel (code_hashes.par_iter().for_each(...), restoring the removed import), or — cheaper still — only re-fetch the hash(es) actually missing rather than the whole chunk, since the batch failure is triggered by one bad hash, not all of them.

  2. Shard-thread panic escalates to a process paniccrates/storage/store.rs:1037

    .flat_map(|h| h.join().expect("account code shard panicked"))

    If a spawned shard thread panics for any reason (allocation failure, an unexpected unwrap deeper in the decode path, etc.), this turns it into an .expect() panic on the calling thread instead of a StoreError. This is a background prefetch/warmer path, so a panic here takes down whatever thread invoked warm_block_from_bal rather than degrading gracefully like the rest of the error handling in this PR does. Worth converting to a mapped Err(StoreError::Custom(...)) for consistency with the "best-effort, never fail the pipeline" theme in the surrounding comments.

  3. All-or-nothing batch semantics discard already-fetched workcrates/storage/store.rs (Store::get_account_codes_batch) / crates/blockchain/vm.rs:317-352 (StoreVmDatabase::get_account_codes_batch) / crates/vm/levm/src/db/mod.rs:280-308 (CachingDatabase::get_account_codes_batch)
    Store::get_account_codes_batch itself is graceful (Vec<Option<Code>>), but both wrapping layers turn any None into a hard Err for the entire batch, so a single missing hash discards results (and cache insertions) for every other, present hash in that call. This is intentional and matches the pre-existing single-hash semantics, and the warmer's fallback (Point 1) papers over it functionally — just flagging that the blast radius of one bad hash is the whole chunk rather than just that hash, which compounds with Point 1's performance cost.

  4. Chunk sizing heuristic assumes 1:1 accounts-to-code-hashescrates/vm/backends/levm/mod.rs:2840
    WARM_CHUNK_ACCOUNTS = 256 * 64 is sized so a chunk's code hash count can reach the shard threshold in Store::get_account_codes_batch. But code_hashes is deduplicated per chunk (crates/vm/backends/levm/mod.rs:2854-2862), and the PR's own rationale notes that contract-code sharing across accounts is common, so a chunk of 16384 accounts will typically yield far fewer than 16384 distinct hashes — likely well under the shard threshold in most real blocks. Not a bug, but the "smallest chunk that still saturates [the shard path]" comment somewhat overstates what the current chunk size guarantees.

  5. Minor test-coverage gapCachingDatabase::get_account_codes_batch (crates/vm/levm/src/db/mod.rs:280-308) has no direct unit test; it's only exercised indirectly through warm_block_from_bal. Given it's the layer that actually gets called by the warmer (not Store::get_account_codes_batch directly), a small test for its dedup-and-cache behavior (e.g., verifying .entry().or_insert() doesn't clobber a concurrently-populated cache entry) would round out the otherwise thorough test additions.

Nothing here indicates a consensus-affecting or memory-safety bug — this is a warmer/prefetch path with best-effort semantics by design, and the StoreVmDatabase/executor-facing error behavior (fail loud on missing code) is unchanged and consistent with the pre-existing single-hash path. Points 1 and 2 are the ones I'd want addressed before merge; 3-5 are lower-priority observations.


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

The warmer read account states for every BAL address before fetching a single
bytecode, so the executor faulted in code itself for the whole of that phase, and
it then issued one point get per code hash.

Warm accounts and code in chunks instead, sized to the smallest batch that still
saturates the store's sharded read path, and add a batched bytecode read that
resolves the buffer and LRU first, shards the remainder across one multi_get per
shard, and decodes each shard in place. Code hashes now come out of the account
read rather than costing a second lookup per account, and the LRU is locked once
per batch instead of twice per code.
@edg-l
edg-l force-pushed the perf/bal-code-prefetch-batch branch from 39bcc7e to ee9e6d6 Compare August 4, 2026 09:13
@github-actions

github-actions Bot commented Aug 4, 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.007 ± 0.022 2.980 3.046 1.20 ± 0.01
main_levm_BubbleSort 2.497 ± 0.016 2.469 2.519 1.00
pr_revm_BubbleSort 2.981 ± 0.022 2.947 3.016 1.19 ± 0.01
pr_levm_BubbleSort 2.508 ± 0.021 2.466 2.540 1.00 ± 0.01

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 982.0 ± 9.1 971.5 1002.1 1.04 ± 0.02
main_levm_ERC20Approval 940.2 ± 15.3 918.5 962.0 1.00
pr_revm_ERC20Approval 964.6 ± 10.4 944.8 977.7 1.03 ± 0.02
pr_levm_ERC20Approval 959.0 ± 11.2 949.5 985.5 1.02 ± 0.02

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 131.8 ± 2.1 128.7 135.2 1.00
main_levm_ERC20Mint 142.0 ± 2.3 136.7 144.4 1.08 ± 0.02
pr_revm_ERC20Mint 131.9 ± 2.0 129.1 134.2 1.00 ± 0.02
pr_levm_ERC20Mint 142.3 ± 1.9 138.9 145.3 1.08 ± 0.02

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 231.2 ± 3.2 226.1 237.4 1.00
main_levm_ERC20Transfer 235.9 ± 2.8 230.6 239.4 1.02 ± 0.02
pr_revm_ERC20Transfer 232.1 ± 4.3 223.9 240.3 1.00 ± 0.02
pr_levm_ERC20Transfer 233.1 ± 4.4 225.4 239.6 1.01 ± 0.02

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 225.9 ± 0.8 224.3 226.8 1.00
main_levm_Factorial 262.7 ± 62.9 240.6 441.7 1.16 ± 0.28
pr_revm_Factorial 227.0 ± 3.2 223.4 233.3 1.01 ± 0.01
pr_levm_Factorial 243.8 ± 0.9 242.9 245.3 1.08 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.676 ± 0.032 1.617 1.726 1.00 ± 0.03
main_levm_FactorialRecursive 9.253 ± 0.036 9.219 9.317 5.55 ± 0.12
pr_revm_FactorialRecursive 1.668 ± 0.035 1.615 1.726 1.00
pr_levm_FactorialRecursive 9.290 ± 0.040 9.233 9.377 5.57 ± 0.12

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 205.3 ± 1.6 202.1 207.8 1.00
main_levm_Fibonacci 217.5 ± 8.6 212.8 241.4 1.06 ± 0.04
pr_revm_Fibonacci 205.9 ± 3.0 200.9 213.1 1.00 ± 0.02
pr_levm_Fibonacci 220.5 ± 17.0 212.5 268.1 1.07 ± 0.08

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 874.9 ± 13.5 844.6 895.7 1.27 ± 0.02
main_levm_FibonacciRecursive 695.5 ± 4.3 688.1 703.7 1.01 ± 0.01
pr_revm_FibonacciRecursive 880.6 ± 9.4 868.9 903.5 1.28 ± 0.02
pr_levm_FibonacciRecursive 689.3 ± 4.7 683.8 698.4 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.6 ± 0.1 8.5 8.8 1.00
main_levm_ManyHashes 9.8 ± 0.3 9.5 10.5 1.13 ± 0.03
pr_revm_ManyHashes 8.6 ± 0.1 8.5 8.7 1.00 ± 0.01
pr_levm_ManyHashes 9.7 ± 0.1 9.5 9.8 1.12 ± 0.01

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 263.5 ± 3.0 258.8 270.1 1.34 ± 0.02
main_levm_MstoreBench 196.8 ± 1.8 194.7 200.5 1.00
pr_revm_MstoreBench 265.3 ± 5.0 257.5 275.0 1.35 ± 0.03
pr_levm_MstoreBench 208.6 ± 27.2 197.3 285.9 1.06 ± 0.14

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 289.4 ± 2.9 283.7 294.3 1.19 ± 0.02
main_levm_Push 243.9 ± 9.5 234.8 269.8 1.01 ± 0.04
pr_revm_Push 290.9 ± 2.1 287.0 292.7 1.20 ± 0.02
pr_levm_Push 242.6 ± 3.3 238.2 248.3 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 170.2 ± 1.0 168.1 171.3 1.66 ± 0.01
main_levm_SstoreBench_no_opt 102.8 ± 0.3 102.2 103.4 1.00
pr_revm_SstoreBench_no_opt 172.3 ± 5.1 168.0 186.3 1.68 ± 0.05
pr_levm_SstoreBench_no_opt 103.1 ± 0.6 102.2 104.3 1.00 ± 0.01

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Benchmark Block Execution Results Comparison Against Main

Command Mean [s] Min [s] Max [s] Relative
base 79.619 ± 0.326 79.037 80.122 1.01 ± 0.01
head 79.050 ± 0.503 78.126 79.935 1.00

Comment thread crates/storage/store.rs Outdated
Comment thread crates/storage/store.rs Outdated
@github-project-automation github-project-automation Bot moved this to In Progress in ethrex_l1 Aug 4, 2026
edg-l added 3 commits August 4, 2026 14:35
…into perf/bal-code-prefetch-batch

# Conflicts:
#	CHANGELOG.md
The shard cap was a constant 64 while the choice between sharding and the point-get
fan-out compared shard count against core count, so on a host with at least 64
threads the sharded path could never be selected. Derive the cap from the core
count instead, so there is always a batch size at which sharding earns its spawns.

The chunk size was derived from that shard geometry, but a chunk bounds accounts
while the code fetch keys on distinct uncached hashes, which is at most that and
usually far fewer. Derive it from the concurrency a fetch must sustain.

Warming code now goes through prefetch_codes, which returns nothing: the previous
shape assembled a result vector under the write lock that guards every executor
code read, for a caller that discarded it. Absent hashes are no longer an error
for the batch either, so the serial per-hash fallback is gone.

Tests compared the batch against a per-hash read taken after it, which the batch's
own cache insert answered; they now assert against independently built codes.
The join() expect escalated a shard panic onto the warmer's thread, which the
best-effort contract around this read does not cover: the fallback handles Err,
not panic. Map it to one Err per key the shard covered, so the results stay
aligned with the request and one bad shard costs the prefetch rather than the
caller.
@edg-l
edg-l requested a review from ElFantasma August 4, 2026 12:50
@github-project-automation github-project-automation Bot moved this from In Progress to In Review in ethrex_l1 Aug 4, 2026
Deriving the shard cap purely from the core count cut read depth on small hosts:
a 12-core box went from 64 shards to 24 on a 22.9k-hash batch, on exactly the
workload this targets. Floor the cap at 64 so those hosts keep the depth they
already reached, and keep the 2x-cores term so the path stays reachable above 32
cores.

Sizing the warmer chunk from the core count made it worse again: a 1024-account
chunk yields ~4 shards, below the gate, so the batch fell back to the fan-out and
sharding never ran. On the blocks this targets the warmer occupies the whole
execution window, so it is throughput-bound and depth beats starting earlier.
Size the chunk to saturate the shard cap instead.
edg-l added 3 commits August 5, 2026 09:12
# Conflicts:
#	crates/vm/db.rs
#	crates/vm/levm/src/db/mod.rs
The warm read every bytecode a BAL's accounts reference, whether or not the
executor would look at it. An opcode reading only account state (BALANCE,
EXTCODEHASH) leaves all of it unread, and on a block of unique max-size
contracts that is gigabytes of reads for nothing, competing with the account
reads the executor is waiting on.

Cap it at the bytecode cache's capacity, since past that the warm evicts what
it just inserted, and fetch a chunk's hashes in slices so the budget and the
cancellation flag are observed part-way through a chunk instead of once per
chunk.

The chunk width itself stays wide: prefetch_accounts routes a chunk to the
sharded batch read only once its cold addresses reach BLOATED_BATCH_THRESHOLD,
so narrowing it would leave that path unreachable.
The budget only has to bind where a block's distinct bytecode outgrows what the
node keeps resident. Cover the other direction: a block that fits warms every
bytecode, once each, in a single batch, exactly as it did before the budget.

Also correct the rationale. The per-block cache the executor reads through is an
unbounded map, so a warm past the budget does not evict what it just inserted;
what it does is spend read bandwidth on bytecode a BAL cannot tell us anyone will
read, and hold it resident.
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 Review
Status: Todo

Development

Successfully merging this pull request may close these issues.

4 participants