Skip to main content

reth_engine_tree/tree/payload_processor/
bal_prewarm_pool.rs

1//! BAL read-set prewarming pool.
2
3use alloy_primitives::{Address, StorageKey};
4use reth_execution_cache::{CachedStateProvider, ExecutionCache, TxPoolPrewarmCacheSnapshot};
5use reth_provider::{
6    AccountReader, BytecodeReader, ProviderResult, StateProvider, StateProviderBox,
7};
8use std::{
9    sync::{
10        atomic::{AtomicUsize, Ordering},
11        Arc,
12    },
13    thread::JoinHandle,
14};
15use tokio::sync::oneshot;
16use tracing::trace;
17
18/// Builds a fresh `StateProviderBox` over the block's parent state. Type-erased so the pool is not
19/// generic over the provider factory; each worker builds its own per block.
20pub type BuildProviderFn = dyn Fn() -> ProviderResult<StateProviderBox> + Send + Sync;
21
22/// A single warm request: a whole account (basic account + its bytecode) followed by a batch of
23/// its storage slots, or a batch of storage slots on their own.
24enum PrewarmTarget {
25    Account(Address, Box<[StorageKey]>),
26    Storage(Address, Box<[StorageKey]>),
27}
28
29/// A message in a worker's queue. The per-block lifecycle is explicit and ordered (the queue is
30/// FIFO): one `BeginBlock`, then the worker's share of `Warm`s, then one `EndBlock`.
31enum PrewarmMsg {
32    /// Open a read txn for the new block: build a provider over the parent state and hold it.
33    BeginBlock {
34        build: Arc<BuildProviderFn>,
35        caches: ExecutionCache,
36        txpool_snapshot: Option<TxPoolPrewarmCacheSnapshot>,
37    },
38    /// Warm one target into the held provider's cache. Ignored if no provider is held.
39    Warm(PrewarmTarget),
40    /// Drop the held provider (and its read txn).
41    EndBlock(Arc<SendOnDrop>),
42}
43
44/// Long-lived pool of blocking threads that warm the BAL read-set into the shared execution cache.
45#[derive(Debug)]
46pub struct BalPrewarmPool {
47    /// One queue per worker. `BeginBlock`/`EndBlock` are broadcast to all; `Warm`s round-robin.
48    workers: Vec<crossbeam_channel::Sender<PrewarmMsg>>,
49    /// Round-robin cursor for distributing warm requests across workers.
50    next: AtomicUsize,
51    _handles: Vec<JoinHandle<()>>,
52}
53
54impl BalPrewarmPool {
55    /// Spawns `num_threads` long-lived blocking worker threads. Owned by the
56    /// [`PayloadProcessor`](super::PayloadProcessor); the threads exit when the pool is dropped.
57    pub fn new(num_threads: usize) -> Arc<Self> {
58        let mut workers = Vec::with_capacity(num_threads);
59        let mut handles = Vec::with_capacity(num_threads);
60        for i in 0..num_threads {
61            let (tx, rx) = crossbeam_channel::unbounded::<PrewarmMsg>();
62            workers.push(tx);
63            handles.push(
64                std::thread::Builder::new()
65                    .name(format!("bal-prewarm-{i:03}"))
66                    .spawn(move || prewarm_loop(rx))
67                    .expect("spawn bal-prewarm thread"),
68            );
69        }
70        trace!(target: "engine::tree::bal_prewarm_pool", num_threads, "BalPrewarmPool spawned");
71        Arc::new(Self { workers, next: AtomicUsize::new(0), _handles: handles })
72    }
73
74    /// Begins a block: hands every worker the provider builder and shared cache so each opens its
75    /// own read txn over the parent state. Pair with [`end_block`](Self::end_block).
76    pub fn begin_block(
77        &self,
78        build: Arc<BuildProviderFn>,
79        caches: ExecutionCache,
80        txpool_snapshot: Option<TxPoolPrewarmCacheSnapshot>,
81    ) {
82        for worker in &self.workers {
83            let _ = worker.send(PrewarmMsg::BeginBlock {
84                build: build.clone(),
85                caches: caches.clone(),
86                txpool_snapshot: txpool_snapshot.clone(),
87            });
88        }
89    }
90
91    /// Fire-and-forget: warm an account (basic account + bytecode) and its storage slots.
92    ///
93    /// The slots are dispatched in `WARM_BATCH_SIZE` chunks that are distributed independently,
94    /// so a single account with a large read-set does not serialize onto one worker;
95    /// [`end_block`](Self::end_block) waits for the slowest queue.
96    pub fn warm_account(&self, addr: Address, slots: impl IntoIterator<Item = StorageKey>) {
97        let mut slots = slots.into_iter();
98        let mut batch: Box<[StorageKey]> = slots.by_ref().take(WARM_BATCH_SIZE).collect();
99        self.send_warm(PrewarmTarget::Account(addr, batch));
100
101        loop {
102            batch = slots.by_ref().take(WARM_BATCH_SIZE).collect();
103            if batch.is_empty() {
104                break
105            }
106            self.send_warm(PrewarmTarget::Storage(addr, batch));
107        }
108    }
109
110    /// Ends the block: every worker drops its provider (and read txn) once it has drained the warm
111    /// requests queued ahead of this message.
112    ///
113    /// Blocks until all workers processed the end block message.
114    pub fn end_block(&self) {
115        let (tx, rx) = oneshot::channel();
116        let tx = Arc::new(SendOnDrop { sender: Some(tx) });
117
118        for worker in &self.workers {
119            let _ = worker.send(PrewarmMsg::EndBlock(tx.clone()));
120        }
121
122        drop(tx);
123        rx.blocking_recv().expect("BAL prewarm pool dropped without signaling completion");
124    }
125
126    fn send_warm(&self, target: PrewarmTarget) {
127        let i = self.next.fetch_add(1, Ordering::Relaxed) % self.workers.len();
128        let _ = self.workers[i].send(PrewarmMsg::Warm(target));
129    }
130}
131
132/// Number of warming threads.
133///
134/// The work performed on those threads boils down mostly to MDBX reads. An MDBX read consists of
135/// a tree traversal and major page faults causing I/O.
136///
137/// In order to utilize the parallelism of `NVMe` we have to give it enough work, or equally,
138/// maintain a high queue depth. Modern `NVMe` devices require in between 64-128 requests in-flight
139/// to achieve its peak performance. Ideally we don't grow past that but it's OK to do so, it just
140/// means that a request is going to wait in the `NVMe` queue rather than in memory.
141///
142/// MDBX piggy-backs on the OS page cache for its buffers. Oftentimes, the hit rate reaches 90-99%
143/// hit rate. At that point, the workload can be classified as CPU-bound. In that case, having
144/// a high number of threads is counterproductive due to the effects of context switching, core
145/// migration, contention, etc.
146///
147/// However, that overhead is considered negligible compared to the benefits of fully utilizing
148/// `NVMe` resources. For example, with request latency of 100µs, 100k IO requests the expected
149/// time to finish is 312.5ms at QD=32 and 156.26ms at QD=64.
150///
151/// This should explain why this particular value is picked.
152pub const DEFAULT_BAL_PREWARM_THREADS: usize = 128;
153
154/// Number of storage slots carried by one warm message.
155///
156/// Batching amortizes the send over many slots and hands the worker a run of slots that live
157/// close together in the storage table, while the cap keeps enough messages in flight to saturate
158/// the workers on blocks whose read-set is concentrated in a few accounts.
159const WARM_BATCH_SIZE: usize = 8;
160
161fn prewarm_loop(rx: crossbeam_channel::Receiver<PrewarmMsg>) {
162    // The provider (and its MDBX read txn) held for the current block, between `BeginBlock` and
163    // `EndBlock`. `None` while idle, so no read txn is pinned across the inter-block gap.
164    let mut provider: Option<CachedStateProvider<StateProviderBox>> = None;
165
166    // Blocks when idle; the channel disconnects (and the loop ends) when the pool is dropped.
167    while let Ok(msg) = rx.recv() {
168        match msg {
169            PrewarmMsg::BeginBlock { build, caches, txpool_snapshot } => {
170                provider = match (build)() {
171                    Ok(inner) => Some(
172                        CachedStateProvider::new_prewarm(inner, caches)
173                            .with_txpool_snapshot(txpool_snapshot),
174                    ),
175                    Err(err) => {
176                        trace!(target: "engine::tree::bal_prewarm_pool", %err, "failed to build provider");
177                        None
178                    }
179                };
180            }
181            PrewarmMsg::Warm(target) => {
182                let Some(provider) = provider.as_ref() else { continue };
183                match target {
184                    PrewarmTarget::Account(addr, slots) => {
185                        if let Ok(Some(account)) = provider.basic_account(&addr) &&
186                            let Some(code_hash) = account.bytecode_hash &&
187                            code_hash != alloy_consensus::constants::KECCAK_EMPTY
188                        {
189                            let _ = provider.bytecode_by_hash(&code_hash);
190                        }
191                        for &slot in &slots {
192                            let _ = provider.storage(addr, slot);
193                        }
194                    }
195                    PrewarmTarget::Storage(addr, slots) => {
196                        for &slot in &slots {
197                            let _ = provider.storage(addr, slot);
198                        }
199                    }
200                }
201            }
202            PrewarmMsg::EndBlock(end_tx) => {
203                provider = None;
204                drop(end_tx);
205            }
206        }
207    }
208}
209struct SendOnDrop {
210    sender: Option<oneshot::Sender<()>>,
211}
212
213impl Drop for SendOnDrop {
214    fn drop(&mut self) {
215        if let Some(sender) = self.sender.take() {
216            let _ = sender.send(());
217        }
218    }
219}