feat: pre-compute trie overlay at persist time with non-blocking rebuild

After persistence, the anchor hash changes and the cached overlay cannot be
reused. Previously, the first block after persist paid O(N) rebuild cost on
the critical validation path, causing p99 latency spikes.

This change spawns a background task after persist completes that:
1. Collects all remaining in-memory blocks
2. Builds a cumulative overlay from their already-computed trie data
3. Updates the tip block's cached overlay with the new anchor

Key design decisions:
- Uses non-blocking try_get()/try_trie_data() to avoid competing with block
  validation - if any trie data is still pending, the task bails out early
- The next block validation will trigger synchronous rebuild as fallback
- Since persist runs every ~500ms and blocks arrive every ~12s, the background
  task almost always succeeds before the next block needs the overlay

New APIs:
- DeferredTrieData::try_get() - returns None if pending (never computes)
- DeferredTrieData::update_cached_overlay() - updates anchor and overlay in-place
- ExecutedBlock::try_trie_data() - non-blocking wrapper around try_get()
This commit is contained in:
yongkangc
2026-01-08 09:12:47 +00:00
parent 8a2eb3031c
commit 190477d6e8
3 changed files with 133 additions and 8 deletions

View File

@@ -232,20 +232,55 @@ impl DeferredTrieData {
fn merge_ancestors_into_overlay(ancestors: &[Self]) -> TrieInputSorted {
let mut overlay = TrieInputSorted::default();
// Hoist Arc::make_mut outside the loop to reduce atomic refcount checks
// from O(N) to O(1) per field. Benchmarks show 4-6x speedup for this pattern.
let state_mut = Arc::make_mut(&mut overlay.state);
let nodes_mut = Arc::make_mut(&mut overlay.nodes);
for ancestor in ancestors {
let ancestor_data = ancestor.wait_cloned();
{
let state_mut = Arc::make_mut(&mut overlay.state);
state_mut.extend_ref(ancestor_data.hashed_state.as_ref());
}
{
let nodes_mut = Arc::make_mut(&mut overlay.nodes);
nodes_mut.extend_ref(ancestor_data.trie_updates.as_ref());
}
state_mut.extend_ref(ancestor_data.hashed_state.as_ref());
nodes_mut.extend_ref(ancestor_data.trie_updates.as_ref());
}
overlay
}
/// Update the cached overlay (anchor + cumulative trie input) in-place.
///
/// This is used after persistence to re-anchor the overlay of the current in-memory tip
/// to the new persisted head and to drop already-persisted ancestors from the overlay.
///
/// # Arguments
/// * `anchor_hash` - The new persisted ancestor hash this overlay is anchored to
/// * `trie_input` - The pre-computed cumulative trie input for the remaining in-memory chain
///
/// # Behavior
/// - For `Ready` state: Updates the `anchored_trie_input` field with new anchor and overlay
/// - For `Pending` state: Does nothing; caller should ensure tip is already computed
pub fn update_cached_overlay(&self, anchor_hash: B256, trie_input: Arc<TrieInputSorted>) {
let mut state = self.state.lock();
if let DeferredState::Ready(bundle) = &mut *state {
bundle.anchored_trie_input = Some(AnchoredTrieInput { anchor_hash, trie_input });
}
// For Pending state, do nothing. Persist-time overlay rebuild is only for blocks
// whose trie data is already computed (expected for canonical tip).
}
/// Returns trie data if already computed, without blocking or computing.
///
/// This is useful for background tasks that should not compete with block validation.
/// Returns `None` if the trie data is still pending (not yet computed by the async task).
///
/// Unlike [`Self::wait_cloned`], this method never triggers synchronous computation.
pub fn try_get(&self) -> Option<ComputedTrieData> {
let state = self.state.lock();
match &*state {
DeferredState::Ready(bundle) => Some(bundle.clone()),
DeferredState::Pending(_) => None,
}
}
/// Returns trie data, computing synchronously if the async task hasn't completed.
///
/// - If the async task has completed (`Ready`), returns the cached result.

View File

@@ -851,6 +851,15 @@ impl<N: NodePrimitives> ExecutedBlock<N> {
self.trie_data.clone()
}
/// Returns trie data if already computed, without blocking or computing.
///
/// This is useful for background tasks that should not compete with block validation.
/// Returns `None` if the trie data is still pending (not yet computed by the async task).
#[inline]
pub fn try_trie_data(&self) -> Option<ComputedTrieData> {
self.trie_data.try_get()
}
/// Returns the hashed state result of the execution outcome.
///
/// May compute trie data synchronously if the deferred task hasn't completed.

View File

@@ -35,6 +35,7 @@ use reth_provider::{
};
use reth_revm::database::StateProviderDatabase;
use reth_stages_api::ControlFlow;
use reth_trie::TrieInputSorted;
use revm::state::EvmState;
use state::TreeState;
use std::{
@@ -1786,9 +1787,89 @@ where
number: self.persistence_state.last_persisted_block.number,
hash: self.persistence_state.last_persisted_block.hash,
});
// Spawn background task to rebuild the overlay for the remaining in-memory chain.
// This pre-computes the overlay anchored to the new persisted block, so the first
// block after persist hits the O(1) reuse path instead of O(N) rebuild.
self.spawn_tip_overlay_rebuild_after_persist();
Ok(())
}
/// Spawns a background task to rebuild the trie overlay for the in-memory chain tip.
///
/// After persistence, the anchor hash changes and the old overlay cannot be reused.
/// Without pre-computation, the first block after persist would pay O(N) rebuild cost
/// on the critical validation path, causing p99 latency spikes.
///
/// This method spawns a background task that:
/// 1. Collects all remaining in-memory blocks (oldest to newest)
/// 2. Builds a cumulative overlay from their already-computed trie data
/// 3. Updates the tip block's cached overlay with the new anchor
///
/// The task uses non-blocking `try_trie_data()` to avoid competing with block validation.
/// If any block's trie data is still pending, the task bails out early - the next block
/// validation will trigger the synchronous rebuild as usual.
///
/// Since persist runs every ~500ms and blocks arrive every ~12s, the background task
/// almost always finishes before the next block needs the overlay.
fn spawn_tip_overlay_rebuild_after_persist(&self) {
let anchor_hash = self.persistence_state.last_persisted_block.hash;
let in_mem = self.canonical_in_memory_state.clone();
rayon::spawn(move || {
// Early exit if there are no in-memory canonical blocks left
let Some(head_state) = in_mem.head_state() else {
return;
};
// Collect the chain [newest -> oldest]
let chain: Vec<_> = head_state.iter().collect();
if chain.is_empty() {
return;
}
// Build cumulative overlay from the remaining in-memory blocks.
// Use try_trie_data() to avoid blocking - if any block's trie data is not
// yet computed, bail out and let the normal validation path handle it.
let mut overlay = TrieInputSorted::default();
{
let state_mut = Arc::make_mut(&mut overlay.state);
let nodes_mut = Arc::make_mut(&mut overlay.nodes);
// Iterate in reverse order (oldest to newest) so later state takes precedence
for block_state in chain.iter().rev() {
// Non-blocking: skip if trie data not ready yet
let Some(trie_data) = block_state.block_ref().try_trie_data() else {
// Trie data not ready - bail out entirely.
// The next block validation will handle the rebuild synchronously.
trace!(
target: "engine::tree",
block_hash = ?block_state.hash(),
"Background overlay rebuild bailed: trie data not ready"
);
return;
};
state_mut.extend_ref(trie_data.hashed_state.as_ref());
nodes_mut.extend_ref(trie_data.trie_updates.as_ref());
}
}
// Update the tip's cached overlay with the new anchor and cumulative overlay.
// The tip is the first element in the chain (newest).
let tip_state = &chain[0];
let tip_trie_handle = tip_state.block_ref().trie_data_handle();
tip_trie_handle.update_cached_overlay(anchor_hash, Arc::new(overlay));
trace!(
target: "engine::tree",
num_blocks = chain.len(),
?anchor_hash,
"Rebuilt trie overlay for tip after persist"
);
});
}
/// Return an [`ExecutedBlock`] from database or in-memory state by hash.
///
/// Note: This function attempts to fetch the `ExecutedBlock` from either in-memory state