Skip to content

Commit b9f9217

Browse files
authored
perf(l1): thread Arc<BlockAccessList> to avoid per-block BAL deep clones (#6829)
Before this change, `execute_block_parallel` in `crates/vm/backends/levm/mod.rs` called `Arc::new(bal.clone())` and `Arc::new(validation_index.clone())` on the critical path -- a full deep copy of the BAL (potentially multiple MB of account/slot/code data) before a single transaction executed, serially. Fix: thread `Option<Arc<BlockAccessList>>` and `Arc<BalAddressIndex>` through the entire pipeline: `add_block_pipeline{,_bal,_with_witness,_inner}`, `Blockchain::execute_block_pipeline`, `Evm::execute_block_pipeline`, `LEVM::execute_block_pipeline`, and `execute_block_parallel`. The owned BAL is wrapped in an `Arc` once at the call boundary (`bal.map(Arc::new)`). The validation index is built directly into an `Arc`. All downstream per-thread (warmer/executor) and per-tx (`LazyBalCursor`) uses become pointer clones. The two deep clones become moves. 7 files touched: `cmd/ethrex/cli.rs`, `crates/blockchain/blockchain.rs`, `crates/networking/p2p/sync/full.rs`, `crates/networking/rpc/rpc.rs`, `crates/vm/backends/levm/mod.rs`, `crates/vm/backends/mod.rs`, `tooling/ef_tests/blockchain/test_runner.rs`. Zero behavior change. `make test` passes (57 binaries, 0 failures). `cargo check --workspace --all-targets` and `cargo clippy` clean. Full EF state/blockchain suites not run locally; they should gate in CI.
1 parent 40947a3 commit b9f9217

9 files changed

Lines changed: 80 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
## Perf
1414

15+
### 2026-06-29
16+
17+
- Thread `Arc<BlockAccessList>` through the block pipeline to avoid an O(BAL-size) deep clone of the Block Access List (and its validation index) per block on the parallel execution path [#6829](https://github.com/lambdaclass/ethrex/pull/6829)
18+
1519
### 2026-06-18
1620

1721
- In-place top-slot mutation for unary/binary opcodes and `MLOAD`: mutate the top stack slot directly instead of pop-then-push, removing the serial read-modify-write of the stack offset on offset-chain-bound ops. ~2.16x on an ISZERO loop and ~1.63x on `MLOAD` (IPC 2.41 -> 3.45 on a 33M-op loop) [#6865](https://github.com/lambdaclass/ethrex/pull/6865)

cmd/ethrex/cli.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,14 +1030,15 @@ pub async fn import_blocks_bench(
10301030
info!(path = %bal_path, "Loading BALs from file (parallel path)");
10311031
use ethrex_common::types::block_access_list::BlockAccessList;
10321032
use ethrex_rlp::decode::RLPDecode as _;
1033+
use std::sync::Arc;
10331034
let data = std::fs::read(bal_path)
10341035
.unwrap_or_else(|e| panic!("failed to read BAL file at {bal_path:?}: {e}"));
10351036
let mut remaining = data.as_slice();
1036-
let mut bals = Vec::new();
1037+
let mut bals: Vec<Arc<BlockAccessList>> = Vec::new();
10371038
while !remaining.is_empty() {
10381039
let (bal, rest) = BlockAccessList::decode_unfinished(remaining)
10391040
.unwrap_or_else(|e| panic!("failed to decode BAL from {bal_path:?}: {e}"));
1040-
bals.push(bal);
1041+
bals.push(Arc::new(bal));
10411042
remaining = rest;
10421043
}
10431044
let amsterdam_blocks = chains
@@ -1104,7 +1105,10 @@ pub async fn import_blocks_bench(
11041105
// BALs are only produced for Amsterdam+ blocks, so use a separate counter
11051106
// that only advances for blocks that have a BAL hash in the header.
11061107
let bal = if block.header.block_access_list_hash.is_some() {
1107-
let b = preloaded_bals.as_ref().and_then(|bals| bals.get(bal_index));
1108+
let b = preloaded_bals
1109+
.as_ref()
1110+
.and_then(|bals| bals.get(bal_index))
1111+
.cloned();
11081112
bal_index += 1;
11091113
b
11101114
} else {

crates/blockchain/blockchain.rs

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ impl Blockchain {
522522
block: &Block,
523523
parent_header: &BlockHeader,
524524
vm: &mut Evm,
525-
bal: Option<&BlockAccessList>,
525+
bal: Option<Arc<BlockAccessList>>,
526526
collect_witness: bool,
527527
) -> Result<BlockExecutionPipelineResult, ChainError> {
528528
let start_instant = Instant::now();
@@ -569,7 +569,7 @@ impl Blockchain {
569569
// consumes — the synthesized path would leave the receiver dropped.
570570
let optimistic_updates: Option<FxHashMap<Address, BalSynthesisItem>> =
571571
if self.options.bal_parallel_trie_enabled && !collect_witness {
572-
bal.map(synthesize_bal_updates)
572+
bal.as_deref().map(synthesize_bal_updates)
573573
} else {
574574
None
575575
};
@@ -611,14 +611,18 @@ impl Blockchain {
611611
#[cfg(all(feature = "rayon", not(feature = "eip-8025")))]
612612
if self.options.bal_prefetch_enabled
613613
&& !collect_witness
614-
&& let Some(bal) = bal
614+
&& let Some(bal_ref) = bal.as_ref()
615615
{
616-
let slots = LEVM::bal_storage_slots(bal);
616+
let slots = LEVM::bal_storage_slots(bal_ref);
617617
if !slots.is_empty() {
618618
let _ = caching_store.prefetch_storage(&slots);
619619
}
620620
}
621621

622+
// Each thread that captures `bal` needs its own Arc clone (cheap pointer bump).
623+
#[cfg(all(feature = "rayon", not(feature = "eip-8025")))]
624+
let bal_warmer = bal.clone();
625+
622626
let (execution_result, merkleization_result, warmer_duration) =
623627
std::thread::scope(|s| -> Result<_, ChainError> {
624628
#[cfg(all(feature = "rayon", not(feature = "eip-8025")))]
@@ -635,11 +639,11 @@ impl Blockchain {
635639
// Warming uses the same caching store, sharing cached state with execution.
636640
// Precompile cache lives inside CachingDatabase, shared automatically.
637641
let start = Instant::now();
638-
if let Some(bal) = bal {
642+
if let Some(bal) = bal_warmer {
639643
if bal_prefetch_enabled {
640644
// Amsterdam+: BAL-based precise prefetching (no tx re-execution).
641645
if let Err(e) = LEVM::warm_block_from_bal(
642-
bal,
646+
&bal,
643647
caching_store,
644648
cancelled_ref,
645649
) {
@@ -701,6 +705,10 @@ impl Blockchain {
701705
let execution_handle = std::thread::Builder::new()
702706
.name("block_executor_execution".to_string())
703707
.spawn_scoped(s, move || -> Result<_, ChainError> {
708+
// Cheap Arc pointer bump: `execute_block_pipeline` takes
709+
// ownership, but the header-commitment check below still needs
710+
// the input BAL on the parallel path (produced_bal == None).
711+
let header_bal = bal.clone();
704712
let result = vm.execute_block_pipeline(
705713
block,
706714
tx,
@@ -761,7 +769,7 @@ impl Blockchain {
761769
block.body.transactions.len(),
762770
&NativeCrypto,
763771
)?;
764-
} else if let Some(header_bal) = bal
772+
} else if let Some(header_bal) = header_bal.as_deref()
765773
&& chain_config.is_amsterdam_activated(block.header.timestamp)
766774
&& !header_bal.matches_commitment(
767775
block.header.block_access_list_hash,
@@ -2051,7 +2059,7 @@ impl Blockchain {
20512059
pub fn add_block_pipeline(
20522060
&self,
20532061
block: Block,
2054-
bal: Option<&BlockAccessList>,
2062+
bal: Option<Arc<BlockAccessList>>,
20552063
) -> Result<(), ChainError> {
20562064
let (_, _, result) = self.add_block_pipeline_inner(block, bal, false)?;
20572065
result
@@ -2066,7 +2074,7 @@ impl Blockchain {
20662074
pub fn add_block_pipeline_bal(
20672075
&self,
20682076
block: Block,
2069-
bal: Option<&BlockAccessList>,
2077+
bal: Option<Arc<BlockAccessList>>,
20702078
) -> Result<Option<BlockAccessList>, ChainError> {
20712079
let (produced_bal, _, result) = self.add_block_pipeline_inner(block, bal, false)?;
20722080
result?;
@@ -2078,7 +2086,7 @@ impl Blockchain {
20782086
pub fn add_block_pipeline_with_witness(
20792087
&self,
20802088
block: Block,
2081-
bal: Option<&BlockAccessList>,
2089+
bal: Option<Arc<BlockAccessList>>,
20822090
) -> Result<ExecutionWitness, ChainError> {
20832091
let (_, witness, result) = self.add_block_pipeline_inner(block, bal, true)?;
20842092
result?;
@@ -2100,7 +2108,7 @@ impl Blockchain {
21002108
fn add_block_pipeline_inner(
21012109
&self,
21022110
block: Block,
2103-
bal: Option<&BlockAccessList>,
2111+
bal: Option<Arc<BlockAccessList>>,
21042112
force_witness: bool,
21052113
) -> Result<AddBlockPipelineInnerResult, ChainError> {
21062114
// Validate if it can be the new head and find the parent
@@ -2143,6 +2151,9 @@ impl Blockchain {
21432151
(vm, None)
21442152
};
21452153

2154+
// Keep a copy of the input BAL Arc for the post-execution BAL store call below.
2155+
// `execute_block_pipeline` takes ownership; this clone is a cheap pointer bump.
2156+
let input_bal = bal.clone();
21462157
let (
21472158
res,
21482159
account_updates_list,
@@ -2193,7 +2204,7 @@ impl Blockchain {
21932204
// On the parallel Amsterdam validation path the BAL is supplied via the header
21942205
// and `produced_bal` is None, so fall back to the validated incoming `bal`.
21952206
// Pre-Amsterdam blocks have no BAL on either source, so nothing is stored.
2196-
if let Some(bal) = produced_bal.as_ref().or(bal)
2207+
if let Some(bal) = produced_bal.as_ref().or(input_bal.as_deref())
21972208
&& let Err(err) = self.storage.store_block_access_list(block_hash, bal)
21982209
{
21992210
warn!("Failed to store block access list for block {block_hash}: {err}");
@@ -2224,16 +2235,18 @@ impl Blockchain {
22242235
);
22252236
}
22262237

2227-
metrics!(if let Some(bal_ref) = produced_bal.as_ref().or(bal) {
2228-
let account_count = bal_ref.accounts().len() as u64;
2229-
let slot_count = bal_ref.item_count().saturating_sub(account_count);
2230-
let size_bytes = bal_ref.length() as f64;
2231-
METRICS_BAL.blocks_total.inc();
2232-
METRICS_BAL.size_bytes.set(size_bytes);
2233-
METRICS_BAL.size_bytes_histogram.observe(size_bytes);
2234-
METRICS_BAL.account_count.set(account_count as i64);
2235-
METRICS_BAL.slot_count.set(slot_count as i64);
2236-
});
2238+
metrics!(
2239+
if let Some(bal_ref) = produced_bal.as_ref().or(input_bal.as_deref()) {
2240+
let account_count = bal_ref.accounts().len() as u64;
2241+
let slot_count = bal_ref.item_count().saturating_sub(account_count);
2242+
let size_bytes = bal_ref.length() as f64;
2243+
METRICS_BAL.blocks_total.inc();
2244+
METRICS_BAL.size_bytes.set(size_bytes);
2245+
METRICS_BAL.size_bytes_histogram.observe(size_bytes);
2246+
METRICS_BAL.account_count.set(account_count as i64);
2247+
METRICS_BAL.slot_count.set(slot_count as i64);
2248+
}
2249+
);
22372250

22382251
Ok((produced_bal, witness, result))
22392252
}

crates/networking/p2p/sync/full.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,7 @@ async fn run_blocks_pipeline(
790790
for (block, bal) in blocks.into_iter().zip(bals.into_iter()) {
791791
let block_hash = block.hash();
792792
blockchain
793-
.add_block_pipeline(block, bal.as_ref())
793+
.add_block_pipeline(block, bal.map(Arc::new))
794794
.map_err(|e| {
795795
(
796796
e,

crates/networking/rpc/rpc.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -452,12 +452,12 @@ pub fn start_block_executor(blockchain: Arc<Blockchain>) -> UnboundedSender<Bloc
452452
.spawn(move || {
453453
while let Some((notify, block, bal, make_witness)) = block_receiver.blocking_recv() {
454454
let result = (|| {
455+
let bal = bal.map(Arc::new);
455456
if make_witness {
456-
let witness =
457-
blockchain.add_block_pipeline_with_witness(block, bal.as_ref())?;
457+
let witness = blockchain.add_block_pipeline_with_witness(block, bal)?;
458458
Ok(Some(witness))
459459
} else {
460-
blockchain.add_block_pipeline(block, bal.as_ref())?;
460+
blockchain.add_block_pipeline(block, bal)?;
461461
Ok(None)
462462
}
463463
})();

crates/vm/backends/levm/mod.rs

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterato
7171
#[cfg(all(feature = "rayon", not(feature = "eip-8025")))]
7272
use rustc_hash::{FxHashMap, FxHashSet};
7373
use std::cmp::min;
74-
#[cfg(all(feature = "rayon", not(feature = "eip-8025")))]
7574
use std::sync::Arc;
7675
#[cfg(all(feature = "rayon", not(feature = "eip-8025")))]
7776
use std::sync::atomic::AtomicBool;
@@ -397,7 +396,7 @@ impl LEVM {
397396
merkleizer: Option<Sender<Vec<AccountUpdate>>>,
398397
queue_length: &AtomicUsize,
399398
crypto: &dyn Crypto,
400-
header_bal: Option<&BlockAccessList>,
399+
header_bal: Option<Arc<BlockAccessList>>,
401400
bal_parallel_exec_enabled: bool,
402401
) -> Result<(BlockExecutionResult, Option<BlockAccessList>), EvmError> {
403402
let chain_config = db.store.get_chain_config()?;
@@ -440,15 +439,15 @@ impl LEVM {
440439
// Note: size cap validation is deferred until after transaction processing
441440
// so that transaction-level errors (e.g. gas allowance exceeded) take
442441
// priority, matching the reference implementation's validation order.
443-
validate_header_bal_indices(bal, block.body.transactions.len())
442+
validate_header_bal_indices(&bal, block.body.transactions.len())
444443
.map_err(|e| EvmError::Custom(e.to_string()))?;
445444

446445
// Outer db has no BAL recorder: header BAL drives validation.
447446
// Per-tx tx_dbs enable a shadow recorder for accessed-entry checks.
448447
Self::prepare_block(block, db, vm_type, crypto)?;
449448

450449
// Build validation index once — shared across parallel execution and post-exec seeding.
451-
let validation_index = bal.build_validation_index();
450+
let validation_index = Arc::new(bal.build_validation_index());
452451

453452
// Drain system call state and snapshot for per-tx db seeding
454453
LEVM::get_state_transitions_tx(db)?;
@@ -459,12 +458,12 @@ impl LEVM {
459458
&transactions_with_sender,
460459
db,
461460
vm_type,
462-
bal,
461+
Arc::clone(&bal),
463462
merkleizer.as_ref(),
464463
queue_length,
465464
system_seed,
466465
crypto,
467-
&validation_index,
466+
Arc::clone(&validation_index),
468467
);
469468

470469
// If parallel execution failed (e.g. BAL validation), still check system
@@ -484,7 +483,7 @@ impl LEVM {
484483
u32::try_from(block.body.transactions.len()).unwrap_or(u32::MAX);
485484
if Self::seed_db_from_bal(
486485
db,
487-
bal,
486+
&bal,
488487
last_tx_idx,
489488
&validation_index.accounts_by_min_index,
490489
)
@@ -507,7 +506,7 @@ impl LEVM {
507506
// Eager seed retained: lazy_bal cursor is per-tx only; outer DB has no cursor.
508507
Self::seed_db_from_bal(
509508
db,
510-
bal,
509+
&bal,
511510
last_tx_idx,
512511
&validation_index.accounts_by_min_index,
513512
)?;
@@ -533,7 +532,7 @@ impl LEVM {
533532
let withdrawal_idx = u32::try_from(block.body.transactions.len())
534533
.map(|n| n.saturating_add(1))
535534
.unwrap_or(u32::MAX);
536-
Self::validate_bal_withdrawal_index(db, bal, withdrawal_idx, &validation_index)?;
535+
Self::validate_bal_withdrawal_index(db, &bal, withdrawal_idx, &validation_index)?;
537536

538537
// Mark storage_reads that occurred during the withdrawal/request phase.
539538
if !unread_storage_reads.is_empty() {
@@ -584,7 +583,7 @@ impl LEVM {
584583

585584
// EIP-7928 size cap: validated after execution so that transaction-level
586585
// errors (e.g. gas allowance exceeded) take priority.
587-
validate_block_access_list_size(&block.header, &chain_config, bal)
586+
validate_block_access_list_size(&block.header, &chain_config, &bal)
588587
.map_err(|e| EvmError::Custom(e.to_string()))?;
589588

590589
return Ok((
@@ -1013,12 +1012,12 @@ impl LEVM {
10131012
txs_with_sender: &[(&Transaction, Address)],
10141013
db: &mut GeneralizedDatabase,
10151014
vm_type: VMType,
1016-
bal: &BlockAccessList,
1015+
bal: Arc<BlockAccessList>,
10171016
merkleizer: Option<&Sender<Vec<AccountUpdate>>>,
10181017
queue_length: &AtomicUsize,
10191018
system_seed: Arc<CacheDB>,
10201019
crypto: &dyn Crypto,
1021-
validation_index: &BalAddressIndex,
1020+
validation_index: Arc<BalAddressIndex>,
10221021
) -> Result<
10231022
(
10241023
Vec<Receipt>,
@@ -1053,7 +1052,7 @@ impl LEVM {
10531052
// Skipped when the caller merkleizes optimistically from the input BAL; the
10541053
// conversion is then redundant work (and does pre-state reads we don't need).
10551054
if let Some(merkleizer) = merkleizer {
1056-
let account_updates = Self::bal_to_account_updates(bal, store.as_ref())?;
1055+
let account_updates = Self::bal_to_account_updates(&bal, store.as_ref())?;
10571056
merkleizer
10581057
.send(account_updates)
10591058
.map_err(|e| EvmError::Custom(format!("merkleizer send failed: {e}")))?;
@@ -1100,9 +1099,9 @@ impl LEVM {
11001099
.is_some_and(|a| a.storage.contains_key(key))
11011100
});
11021101

1103-
// Small capacity hint — per-tx DBs materialize only touched accounts via lazy_bal cursor.
1104-
let arc_bal = Arc::new(bal.clone());
1105-
let arc_idx = Arc::new(validation_index.clone());
1102+
// Already-owned Arcs from the caller; per-thread/per-tx uses below are pointer clones.
1103+
let arc_bal = bal;
1104+
let arc_idx = validation_index;
11061105

11071106
// 2. Execute all txs in parallel (embarrassingly parallel, BAL-seeded).
11081107
// BAL validation runs INSIDE the par_iter closure (parallel) but its
@@ -1131,6 +1130,7 @@ impl LEVM {
11311130
.into_par_iter()
11321131
.map(|tx_idx| -> Result<_, EvmError> {
11331132
let (tx, sender) = &txs_with_sender[tx_idx];
1133+
// Small capacity hint — per-tx DBs materialize only touched accounts via lazy_bal cursor.
11341134
let mut tx_db = GeneralizedDatabase::new_with_shared_base_and_capacity(
11351135
store.clone(),
11361136
system_seed.clone(),
@@ -1230,8 +1230,8 @@ impl LEVM {
12301230
seed_idx,
12311231
&current_state,
12321232
&codes,
1233-
bal,
1234-
validation_index,
1233+
&arc_bal,
1234+
&arc_idx,
12351235
&system_seed,
12361236
&store,
12371237
)
@@ -1241,19 +1241,19 @@ impl LEVM {
12411241

12421242
// EIP-7928 (Group B): missing-access detection via shadow recorder.
12431243
for addr in &shadow_touched {
1244-
if !validation_index.addr_to_idx.contains_key(addr) {
1244+
if !arc_idx.addr_to_idx.contains_key(addr) {
12451245
return Err(EvmError::Custom(format!(
12461246
"BAL validation failed for tx {tx_idx}: account {addr:?} was \
12471247
accessed during execution but is missing from BAL"
12481248
)));
12491249
}
12501250
}
12511251
for (addr, slot) in &shadow_reads {
1252-
let Some(&bal_acct_idx) = validation_index.addr_to_idx.get(addr) else {
1252+
let Some(&bal_acct_idx) = arc_idx.addr_to_idx.get(addr) else {
12531253
// Already caught by the touched-address check above.
12541254
continue;
12551255
};
1256-
let acct = &bal.accounts()[bal_acct_idx];
1256+
let acct = &arc_bal.accounts()[bal_acct_idx];
12571257
let in_changes = acct
12581258
.storage_changes
12591259
.binary_search_by(|sc| sc.slot.cmp(slot))

crates/vm/backends/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ impl Evm {
111111
block: &Block,
112112
merkleizer: Option<Sender<Vec<AccountUpdate>>>,
113113
queue_length: &AtomicUsize,
114-
bal: Option<&BlockAccessList>,
114+
bal: Option<Arc<BlockAccessList>>,
115115
bal_parallel_exec_enabled: bool,
116116
) -> Result<(BlockExecutionResult, Option<BlockAccessList>), EvmError> {
117117
LEVM::execute_block_pipeline(

0 commit comments

Comments
 (0)