From c137ed836f1094ee0c44652e5a1626962c11782b Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin Date: Fri, 23 Jan 2026 17:57:42 +0000 Subject: [PATCH] perf(engine): fixed-cache for execution cache (#21128) Co-authored-by: Georgios Konstantopoulos Co-authored-by: Tempo AI --- Cargo.lock | 145 +-- Cargo.toml | 2 +- crates/e2e-test-utils/src/lib.rs | 5 +- crates/e2e-test-utils/src/setup_builder.rs | 8 +- crates/engine/primitives/src/config.rs | 20 +- crates/engine/tree/Cargo.toml | 2 +- crates/engine/tree/src/tree/cached_state.rs | 1051 ++++++++++------- .../tree/src/tree/payload_processor/mod.rs | 87 +- .../src/tree/payload_processor/multiproof.rs | 5 +- .../src/tree/payload_processor/prewarm.rs | 2 +- crates/node/core/src/args/engine.rs | 6 +- crates/node/core/src/node_config.rs | 2 +- 12 files changed, 715 insertions(+), 620 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b59ce1402..13f08781ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,9 +106,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy-chains" -version = "0.2.28" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3842d8c52fcd3378039f4703dba392dca8b546b1c8ed6183048f8dab95b2be78" +checksum = "ef3a72a2247c34a8545ee99e562b1b9b69168e5000567257ae51e91b4e6b1193" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -495,7 +495,7 @@ dependencies = [ "async-stream", "async-trait", "auto_impl", - "dashmap 6.1.0", + "dashmap", "either", "futures", "futures-utils-wasm", @@ -1774,7 +1774,7 @@ dependencies = [ "bytemuck", "cfg-if", "cow-utils", - "dashmap 6.1.0", + "dashmap", "dynify", "fast-float2", "float16", @@ -1968,12 +1968,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - [[package]] name = "bytemuck" version = "1.24.0" @@ -2063,19 +2057,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "cargo_metadata" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" -dependencies = [ - "camino", - "cargo-platform 0.1.9", - "semver 1.0.27", - "serde", - "serde_json", -] - [[package]] name = "cargo_metadata" version = "0.19.2" @@ -2127,9 +2108,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.53" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "jobserver", @@ -2930,19 +2911,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -3495,15 +3463,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-chain" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" -dependencies = [ - "version_check", -] - [[package]] name = "ethereum_hashing" version = "0.7.0" @@ -4082,11 +4041,12 @@ checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "fixed-cache" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d3af83468398d500e9bc19e001812dcb1a11e4d3d6a5956c789aa3c11a8cb5" +checksum = "0aaafa7294e9617eb29e5c684a3af33324ef512a1bf596af2d1938a03798da29" dependencies = [ "equivalent", + "typeid", ] [[package]] @@ -4851,7 +4811,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -5967,21 +5927,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "mini-moka" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" -dependencies = [ - "crossbeam-channel", - "crossbeam-utils", - "dashmap 5.5.3", - "skeptic", - "smallvec", - "tagptr", - "triomphe", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6515,9 +6460,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl-probe" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "opentelemetry" @@ -7022,9 +6967,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -7172,17 +7117,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "pulldown-cmark" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" -dependencies = [ - "bitflags 2.10.0", - "memchr", - "unicase", -] - [[package]] name = "quanta" version = "0.12.6" @@ -7226,7 +7160,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -7263,16 +7197,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -8465,13 +8399,13 @@ dependencies = [ "assert_matches", "codspeed-criterion-compat", "crossbeam-channel", - "dashmap 6.1.0", + "dashmap", "derive_more", "eyre", + "fixed-cache", "futures", "metrics", "metrics-util", - "mini-moka", "moka", "parking_lot", "proptest", @@ -9111,7 +9045,7 @@ dependencies = [ "bitflags 2.10.0", "byteorder", "codspeed-criterion-compat", - "dashmap 6.1.0", + "dashmap", "derive_more", "parking_lot", "rand 0.9.2", @@ -10218,7 +10152,7 @@ dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", "assert_matches", - "dashmap 6.1.0", + "dashmap", "eyre", "itertools 0.14.0", "metrics", @@ -11245,7 +11179,7 @@ dependencies = [ "alloy-rlp", "codspeed-criterion-compat", "crossbeam-channel", - "dashmap 6.1.0", + "dashmap", "derive_more", "itertools 0.14.0", "metrics", @@ -12381,21 +12315,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" -[[package]] -name = "skeptic" -version = "0.13.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" -dependencies = [ - "bytecount", - "cargo_metadata 0.14.2", - "error-chain", - "glob", - "pulldown-cmark", - "tempfile", - "walkdir", -] - [[package]] name = "sketches-ddsketch" version = "0.3.0" @@ -12464,9 +12383,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -12956,7 +12875,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -13436,12 +13355,6 @@ dependencies = [ "rlp", ] -[[package]] -name = "triomphe" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" - [[package]] name = "try-lock" version = "0.2.5" @@ -13467,6 +13380,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index bc5df15fb5..9e83760c22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -588,7 +588,7 @@ tracing-appender = "0.2" url = { version = "2.3", default-features = false } zstd = "0.13" byteorder = "1" -mini-moka = "0.10" +fixed-cache = { version = "0.1.7", features = ["stats"] } moka = "0.12" tar-no-std = { version = "0.3.2", default-features = false } miniz_oxide = { version = "0.8.4", default-features = false } diff --git a/crates/e2e-test-utils/src/lib.rs b/crates/e2e-test-utils/src/lib.rs index f5a2d1b030..aadf101eb7 100644 --- a/crates/e2e-test-utils/src/lib.rs +++ b/crates/e2e-test-utils/src/lib.rs @@ -103,7 +103,10 @@ where N: NodeBuilderHelper, { E2ETestSetupBuilder::new(num_nodes, chain_spec, attributes_generator) - .with_tree_config_modifier(move |_| tree_config.clone()) + .with_tree_config_modifier(move |base| { + // Apply caller's tree_config but preserve the small cache size from base + tree_config.clone().with_cross_block_cache_size(base.cross_block_cache_size()) + }) .with_node_config_modifier(move |config| config.set_dev(is_dev)) .with_connect_nodes(connect_nodes) .build() diff --git a/crates/e2e-test-utils/src/setup_builder.rs b/crates/e2e-test-utils/src/setup_builder.rs index 8f38b66eb5..30f7b1d28a 100644 --- a/crates/e2e-test-utils/src/setup_builder.rs +++ b/crates/e2e-test-utils/src/setup_builder.rs @@ -112,11 +112,13 @@ where ..NetworkArgs::default() }; - // Apply tree config modifier if present + // Apply tree config modifier if present, with test-appropriate defaults + let base_tree_config = + reth_node_api::TreeConfig::default().with_cross_block_cache_size(1024 * 1024); let tree_config = if let Some(modifier) = self.tree_config_modifier { - modifier(reth_node_api::TreeConfig::default()) + modifier(base_tree_config) } else { - reth_node_api::TreeConfig::default() + base_tree_config }; let mut nodes = (0..self.num_nodes) diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index 0b72e1d624..20902705dc 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -50,7 +50,17 @@ pub const DEFAULT_PREWARM_MAX_CONCURRENCY: usize = 16; const DEFAULT_BLOCK_BUFFER_LIMIT: u32 = EPOCH_SLOTS as u32 * 2; const DEFAULT_MAX_INVALID_HEADER_CACHE_LENGTH: u32 = 256; const DEFAULT_MAX_EXECUTE_BLOCK_BATCH_SIZE: usize = 4; -const DEFAULT_CROSS_BLOCK_CACHE_SIZE: u64 = 4 * 1024 * 1024 * 1024; +const DEFAULT_CROSS_BLOCK_CACHE_SIZE: usize = default_cross_block_cache_size(); + +const fn default_cross_block_cache_size() -> usize { + if cfg!(test) { + 1024 * 1024 // 1 MB in tests + } else if cfg!(target_pointer_width = "32") { + usize::MAX // max possible on wasm32 / 32-bit + } else { + 4 * 1024 * 1024 * 1024 // 4 GB on 64-bit + } +} /// Determines if the host has enough parallelism to run the payload processor. /// @@ -105,7 +115,7 @@ pub struct TreeConfig { /// Whether to enable state provider metrics. state_provider_metrics: bool, /// Cross-block cache size in bytes. - cross_block_cache_size: u64, + cross_block_cache_size: usize, /// Whether the host has enough parallelism to run state root task. has_enough_parallelism: bool, /// Whether multiproof task should chunk proof targets. @@ -193,7 +203,7 @@ impl TreeConfig { disable_prewarming: bool, disable_parallel_sparse_trie: bool, state_provider_metrics: bool, - cross_block_cache_size: u64, + cross_block_cache_size: usize, has_enough_parallelism: bool, multiproof_chunking_enabled: bool, multiproof_chunk_size: usize, @@ -321,7 +331,7 @@ impl TreeConfig { } /// Returns the cross-block cache size. - pub const fn cross_block_cache_size(&self) -> u64 { + pub const fn cross_block_cache_size(&self) -> usize { self.cross_block_cache_size } @@ -424,7 +434,7 @@ impl TreeConfig { } /// Setter for cross block cache size. - pub const fn with_cross_block_cache_size(mut self, cross_block_cache_size: u64) -> Self { + pub const fn with_cross_block_cache_size(mut self, cross_block_cache_size: usize) -> Self { self.cross_block_cache_size = cross_block_cache_size; self } diff --git a/crates/engine/tree/Cargo.toml b/crates/engine/tree/Cargo.toml index b2124098ea..4f2a4540ba 100644 --- a/crates/engine/tree/Cargo.toml +++ b/crates/engine/tree/Cargo.toml @@ -53,7 +53,7 @@ revm-primitives.workspace = true futures.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "macros"] } -mini-moka = { workspace = true, features = ["sync"] } +fixed-cache.workspace = true moka = { workspace = true, features = ["sync"] } smallvec.workspace = true diff --git a/crates/engine/tree/src/tree/cached_state.rs b/crates/engine/tree/src/tree/cached_state.rs index 0f0b23b4ea..dfdcafa49e 100644 --- a/crates/engine/tree/src/tree/cached_state.rs +++ b/crates/engine/tree/src/tree/cached_state.rs @@ -1,7 +1,11 @@ //! Execution cache implementation for block processing. -use alloy_primitives::{Address, StorageKey, StorageValue, B256}; -use metrics::Gauge; -use mini_moka::sync::CacheBuilder; +use alloy_primitives::{ + map::{DefaultHashBuilder, FbBuildHasher}, + Address, StorageKey, StorageValue, B256, +}; +use fixed_cache::{AnyRef, CacheConfig, Stats, StatsHandler}; +use metrics::{Counter, Gauge, Histogram}; +use parking_lot::Once; use reth_errors::ProviderResult; use reth_metrics::Metrics; use reth_primitives_traits::{Account, Bytecode}; @@ -14,12 +18,62 @@ use reth_trie::{ updates::TrieUpdates, AccountProof, HashedPostState, HashedStorage, MultiProof, MultiProofTargets, StorageMultiProof, StorageProof, TrieInput, }; -use revm_primitives::map::DefaultHashBuilder; -use std::{sync::Arc, time::Duration}; -use tracing::{debug_span, instrument, trace}; +use revm_primitives::eip7907::MAX_CODE_SIZE; +use std::{ + mem::size_of, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tracing::{debug_span, instrument, trace, warn}; -pub(crate) type Cache = - mini_moka::sync::Cache; +/// Alignment in bytes for entries in the fixed-cache. +/// +/// Each bucket in `fixed-cache` is aligned to 128 bytes (cache line) due to +/// `#[repr(C, align(128))]` on the internal `Bucket` struct. +const FIXED_CACHE_ALIGNMENT: usize = 128; + +/// Overhead per entry in the fixed-cache (the `AtomicUsize` tag field). +const FIXED_CACHE_ENTRY_OVERHEAD: usize = size_of::(); + +/// Calculates the actual size of a fixed-cache entry for a given key-value pair. +/// +/// The entry size is `overhead + size_of::() + size_of::()`, rounded up to the +/// next multiple of [`FIXED_CACHE_ALIGNMENT`] (128 bytes). +const fn fixed_cache_entry_size() -> usize { + fixed_cache_key_size_with_value::(size_of::()) +} + +/// Calculates the actual size of a fixed-cache entry for a given key-value pair. +/// +/// The entry size is `overhead + size_of::() + size_of::()`, rounded up to the +/// next multiple of [`FIXED_CACHE_ALIGNMENT`] (128 bytes). +const fn fixed_cache_key_size_with_value(value: usize) -> usize { + let raw_size = FIXED_CACHE_ENTRY_OVERHEAD + size_of::() + value; + // Round up to next multiple of alignment + raw_size.div_ceil(FIXED_CACHE_ALIGNMENT) * FIXED_CACHE_ALIGNMENT +} + +/// Size in bytes of a single code cache entry. +const CODE_CACHE_ENTRY_SIZE: usize = fixed_cache_key_size_with_value::
(MAX_CODE_SIZE); + +/// Size in bytes of a single storage cache entry. +const STORAGE_CACHE_ENTRY_SIZE: usize = + fixed_cache_entry_size::<(Address, StorageKey), StorageValue>(); + +/// Size in bytes of a single account cache entry. +const ACCOUNT_CACHE_ENTRY_SIZE: usize = fixed_cache_entry_size::>(); + +/// Cache configuration with epoch tracking enabled for O(1) cache invalidation. +struct EpochCacheConfig; +impl CacheConfig for EpochCacheConfig { + const EPOCHS: bool = true; +} + +/// Type alias for the fixed-cache used for accounts and storage. +type FixedCache = fixed_cache::Cache; /// A wrapper of a state provider and a shared cache. pub(crate) struct CachedStateProvider { @@ -71,45 +125,63 @@ impl CachedStateProvider { } } -/// Metrics for the cached state provider, showing hits / misses for each cache +/// Metrics for the cached state provider, showing hits / misses / size for each cache. +/// +/// This struct combines both the provider-level metrics (hits/misses tracked by the provider) +/// and the fixed-cache internal stats (collisions, size, capacity). #[derive(Metrics, Clone)] #[metrics(scope = "sync.caching")] pub(crate) struct CachedStateMetrics { + /// Number of times a new execution cache was created + execution_cache_created_total: Counter, + + /// Duration of execution cache creation in seconds + execution_cache_creation_duration_seconds: Histogram, + /// Code cache hits code_cache_hits: Gauge, /// Code cache misses code_cache_misses: Gauge, - /// Code cache size - /// - /// NOTE: this uses the moka caches' `entry_count`, NOT the `weighted_size` method to calculate - /// size. + /// Code cache size (number of entries) code_cache_size: Gauge, + /// Code cache capacity (maximum entries) + code_cache_capacity: Gauge, + + /// Code cache collisions (hash collisions causing eviction) + code_cache_collisions: Gauge, + /// Storage cache hits storage_cache_hits: Gauge, /// Storage cache misses storage_cache_misses: Gauge, - /// Storage cache size - /// - /// NOTE: this uses the moka caches' `entry_count`, NOT the `weighted_size` method to calculate - /// size. + /// Storage cache size (number of entries) storage_cache_size: Gauge, + /// Storage cache capacity (maximum entries) + storage_cache_capacity: Gauge, + + /// Storage cache collisions (hash collisions causing eviction) + storage_cache_collisions: Gauge, + /// Account cache hits account_cache_hits: Gauge, /// Account cache misses account_cache_misses: Gauge, - /// Account cache size - /// - /// NOTE: this uses the moka caches' `entry_count`, NOT the `weighted_size` method to calculate - /// size. + /// Account cache size (number of entries) account_cache_size: Gauge, + + /// Account cache capacity (maximum entries) + account_cache_capacity: Gauge, + + /// Account cache collisions (hash collisions causing eviction) + account_cache_collisions: Gauge, } impl CachedStateMetrics { @@ -118,14 +190,17 @@ impl CachedStateMetrics { // code cache self.code_cache_hits.set(0); self.code_cache_misses.set(0); + self.code_cache_collisions.set(0); // storage cache self.storage_cache_hits.set(0); self.storage_cache_misses.set(0); + self.storage_cache_collisions.set(0); // account cache self.account_cache_hits.set(0); self.account_cache_misses.set(0); + self.account_cache_collisions.set(0); } /// Returns a new zeroed-out instance of [`CachedStateMetrics`]. @@ -134,35 +209,135 @@ impl CachedStateMetrics { zeroed.reset(); zeroed } + + /// Records a new execution cache creation with its duration. + pub(crate) fn record_cache_creation(&self, duration: Duration) { + self.execution_cache_created_total.increment(1); + self.execution_cache_creation_duration_seconds.record(duration.as_secs_f64()); + } +} + +/// A stats handler for fixed-cache that tracks collisions and size. +/// +/// Note: Hits and misses are tracked directly by the [`CachedStateProvider`] via +/// [`CachedStateMetrics`], not here. The stats handler is used for: +/// - Collision detection (hash collisions causing eviction of a different key) +/// - Size tracking +/// +/// ## Size Tracking +/// +/// Size is tracked via `on_insert` and `on_remove` callbacks: +/// - `on_insert`: increment size only when inserting into an empty bucket (no eviction) +/// - `on_remove`: always decrement size +/// +/// Collisions (evicting a different key) don't change size since they replace an existing entry. +#[derive(Debug)] +pub(crate) struct CacheStatsHandler { + collisions: AtomicU64, + size: AtomicUsize, + capacity: usize, +} + +impl CacheStatsHandler { + /// Creates a new stats handler with all counters initialized to zero. + pub(crate) const fn new(capacity: usize) -> Self { + Self { collisions: AtomicU64::new(0), size: AtomicUsize::new(0), capacity } + } + + /// Returns the number of cache collisions. + pub(crate) fn collisions(&self) -> u64 { + self.collisions.load(Ordering::Relaxed) + } + + /// Returns the current size (number of entries). + pub(crate) fn size(&self) -> usize { + self.size.load(Ordering::Relaxed) + } + + /// Returns the capacity (maximum number of entries). + pub(crate) const fn capacity(&self) -> usize { + self.capacity + } + + /// Increments the size counter. Called on cache insert. + pub(crate) fn increment_size(&self) { + let _ = self.size.fetch_add(1, Ordering::Relaxed); + } + + /// Decrements the size counter. Called on cache remove. + pub(crate) fn decrement_size(&self) { + let _ = self.size.fetch_sub(1, Ordering::Relaxed); + } + + /// Resets size to zero. Called on cache clear. + pub(crate) fn reset_size(&self) { + self.size.store(0, Ordering::Relaxed); + } + + /// Resets collision counter to zero (but not size). + pub(crate) fn reset_stats(&self) { + self.collisions.store(0, Ordering::Relaxed); + } +} + +impl StatsHandler for CacheStatsHandler { + fn on_hit(&self, _key: &K, _value: &V) {} + + fn on_miss(&self, _key: AnyRef<'_>) {} + + fn on_insert(&self, key: &K, _value: &V, evicted: Option<(&K, &V)>) { + match evicted { + None => { + // Inserting into an empty bucket + self.increment_size(); + } + Some((evicted_key, _)) if evicted_key != key => { + // Collision: evicting a different key + self.collisions.fetch_add(1, Ordering::Relaxed); + } + Some(_) => { + // Updating the same key, size unchanged + } + } + } + + fn on_remove(&self, _key: &K, _value: &V) { + self.decrement_size(); + } } impl AccountReader for CachedStateProvider { fn basic_account(&self, address: &Address) -> ProviderResult> { - if let Some(res) = self.caches.account_cache.get(address) { - self.metrics.account_cache_hits.increment(1); - return Ok(res) - } - - self.metrics.account_cache_misses.increment(1); - - let res = self.state_provider.basic_account(address)?; - if self.is_prewarm() { - self.caches.account_cache.insert(*address, res); + match self.caches.get_or_try_insert_account_with(*address, || { + self.state_provider.basic_account(address) + })? { + CachedStatus::NotCached(value) => { + self.metrics.account_cache_misses.increment(1); + Ok(value) + } + CachedStatus::Cached(value) => { + self.metrics.account_cache_hits.increment(1); + Ok(value) + } + } + } else if let Some(account) = self.caches.account_cache.get(address) { + self.metrics.account_cache_hits.increment(1); + Ok(account) + } else { + self.metrics.account_cache_misses.increment(1); + self.state_provider.basic_account(address) } - Ok(res) } } -/// Represents the status of a storage slot in the cache. +/// Represents the status of a key in the cache. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum SlotStatus { - /// The account's storage cache doesn't exist. - NotCached, - /// The storage slot exists in cache and is empty (value is zero). - Empty, - /// The storage slot exists in cache and has a specific non-zero value. - Value(StorageValue), +pub(crate) enum CachedStatus { + /// The key is not in the cache (or was invalidated). The value was recalculated. + NotCached(T), + /// The key exists in cache and has a specific value. + Cached(T), } impl StateProvider for CachedStateProvider { @@ -171,54 +346,55 @@ impl StateProvider for CachedStateProvider { account: Address, storage_key: StorageKey, ) -> ProviderResult> { - match self.caches.get_storage(&account, &storage_key) { - (SlotStatus::NotCached, maybe_cache) => { - let final_res = self.state_provider.storage(account, storage_key)?; - - if self.is_prewarm() { - let account_cache = maybe_cache.unwrap_or_default(); - account_cache.insert_storage(storage_key, final_res); - // we always need to insert the value to update the weights. - // Note: there exists a race when the storage cache did not exist yet and two - // consumers looking up the a storage value for this account for the first time, - // however we can assume that this will only happen for the very first - // (mostlikely the same) value, and don't expect that this - // will accidentally replace an account storage cache with - // additional values. - self.caches.insert_storage_cache(account, account_cache); + if self.is_prewarm() { + match self.caches.get_or_try_insert_storage_with(account, storage_key, || { + self.state_provider.storage(account, storage_key).map(Option::unwrap_or_default) + })? { + CachedStatus::NotCached(value) => { + self.metrics.storage_cache_misses.increment(1); + // The slot that was never written to is indistinguishable from a slot + // explicitly set to zero. We return `None` in both cases. + Ok(Some(value).filter(|v| !v.is_zero())) + } + CachedStatus::Cached(value) => { + self.metrics.storage_cache_hits.increment(1); + // The slot that was never written to is indistinguishable from a slot + // explicitly set to zero. We return `None` in both cases. + Ok(Some(value).filter(|v| !v.is_zero())) } - - self.metrics.storage_cache_misses.increment(1); - Ok(final_res) - } - (SlotStatus::Empty, _) => { - self.metrics.storage_cache_hits.increment(1); - Ok(None) - } - (SlotStatus::Value(value), _) => { - self.metrics.storage_cache_hits.increment(1); - Ok(Some(value)) } + } else if let Some(value) = self.caches.storage_cache.get(&(account, storage_key)) { + self.metrics.storage_cache_hits.increment(1); + Ok(Some(value).filter(|v| !v.is_zero())) + } else { + self.metrics.storage_cache_misses.increment(1); + self.state_provider.storage(account, storage_key) } } } impl BytecodeReader for CachedStateProvider { fn bytecode_by_hash(&self, code_hash: &B256) -> ProviderResult> { - if let Some(res) = self.caches.code_cache.get(code_hash) { - self.metrics.code_cache_hits.increment(1); - return Ok(res) - } - - self.metrics.code_cache_misses.increment(1); - - let final_res = self.state_provider.bytecode_by_hash(code_hash)?; - if self.is_prewarm() { - self.caches.code_cache.insert(*code_hash, final_res.clone()); + match self.caches.get_or_try_insert_code_with(*code_hash, || { + self.state_provider.bytecode_by_hash(code_hash) + })? { + CachedStatus::NotCached(code) => { + self.metrics.code_cache_misses.increment(1); + Ok(code) + } + CachedStatus::Cached(code) => { + self.metrics.code_cache_hits.increment(1); + Ok(code) + } + } + } else if let Some(code) = self.caches.code_cache.get(code_hash) { + self.metrics.code_cache_hits.increment(1); + Ok(code) + } else { + self.metrics.code_cache_misses.increment(1); + self.state_provider.bytecode_by_hash(code_hash) } - - Ok(final_res) } } @@ -291,18 +467,6 @@ impl StorageRootProvider for CachedStateProvider { self.state_provider.storage_proof(address, slot, hashed_storage) } - /// Generate a storage multiproof for multiple storage slots. - /// - /// A **storage multiproof** is a cryptographic proof that can verify the values - /// of multiple storage slots for a single account in a single verification step. - /// Instead of generating separate proofs for each slot (which would be inefficient), - /// a multiproof bundles the necessary trie nodes to prove all requested slots. - /// - /// ## How it works: - /// 1. Takes an account address and a list of storage slot keys - /// 2. Traverses the account's storage trie to collect proof nodes - /// 3. Returns a [`StorageMultiProof`] containing the minimal set of trie nodes needed to verify - /// all the requested storage slots fn storage_multiproof( &self, address: Address, @@ -338,89 +502,166 @@ impl HashedPostStateProvider for CachedStateProvider /// Optimizes state access by maintaining in-memory copies of frequently accessed /// accounts, storage slots, and bytecode. Works in conjunction with prewarming /// to reduce database I/O during block execution. +/// +/// ## Storage Invalidation +/// +/// Since EIP-6780, SELFDESTRUCT only works within the same transaction where the +/// contract was created, so we don't need to handle clearing the storage. #[derive(Debug, Clone)] pub(crate) struct ExecutionCache { /// Cache for contract bytecode, keyed by code hash. - code_cache: Cache>, + code_cache: Arc, FbBuildHasher<32>>>, - /// Per-account storage cache: outer cache keyed by Address, inner cache tracks that account’s - /// storage slots. - storage_cache: Cache>, + /// Flat storage cache: maps `(Address, StorageKey)` to storage value. + storage_cache: Arc>, /// Cache for basic account information (nonce, balance, code hash). - account_cache: Cache>, + account_cache: Arc, FbBuildHasher<20>>>, + + /// Stats handler for the code cache. + code_stats: Arc, + + /// Stats handler for the storage cache. + storage_stats: Arc, + + /// Stats handler for the account cache. + account_stats: Arc, + + /// One-time notification when SELFDESTRUCT is encountered + selfdestruct_encountered: Arc, } impl ExecutionCache { - /// Get storage value from hierarchical cache. + /// Minimum cache size required when epochs are enabled. + /// With EPOCHS=true, fixed-cache requires 12 bottom bits to be zero (2 needed + 10 epoch). + const MIN_CACHE_SIZE_WITH_EPOCHS: usize = 1 << 12; // 4096 + + /// Converts a byte size to number of cache entries, rounding down to a power of two. /// - /// Returns a tuple of: - /// - `SlotStatus` indicating whether: - /// - `NotCached`: The account's storage cache doesn't exist - /// - `Empty`: The slot exists in the account's cache but is empty - /// - `Value`: The slot exists and has a specific value - /// - `Option>`: The account's storage cache if it exists - pub(crate) fn get_storage( - &self, - address: &Address, - key: &StorageKey, - ) -> (SlotStatus, Option>) { - match self.storage_cache.get(address) { - None => (SlotStatus::NotCached, None), - Some(account_cache) => { - let status = account_cache.get_storage(key); - (status, Some(account_cache)) - } + /// Fixed-cache requires power-of-two sizes for efficient indexing. + /// With epochs enabled, the minimum size is 4096 entries. + pub(crate) const fn bytes_to_entries(size_bytes: usize, entry_size: usize) -> usize { + let entries = size_bytes / entry_size; + // Round down to nearest power of two + let rounded = if entries == 0 { 1 } else { (entries + 1).next_power_of_two() >> 1 }; + // Ensure minimum size for epoch tracking + if rounded < Self::MIN_CACHE_SIZE_WITH_EPOCHS { + Self::MIN_CACHE_SIZE_WITH_EPOCHS + } else { + rounded } } - /// Insert storage value into hierarchical cache - #[cfg(test)] + /// Build an [`ExecutionCache`] struct, so that execution caches can be easily cloned. + pub(crate) fn new(total_cache_size: usize) -> Self { + let storage_cache_size = (total_cache_size * 8888) / 10000; // 88.88% of total + let account_cache_size = (total_cache_size * 556) / 10000; // 5.56% of total + let code_cache_size = (total_cache_size * 556) / 10000; // 5.56% of total + + let code_capacity = Self::bytes_to_entries(code_cache_size, CODE_CACHE_ENTRY_SIZE); + let storage_capacity = Self::bytes_to_entries(storage_cache_size, STORAGE_CACHE_ENTRY_SIZE); + let account_capacity = Self::bytes_to_entries(account_cache_size, ACCOUNT_CACHE_ENTRY_SIZE); + + let code_stats = Arc::new(CacheStatsHandler::new(code_capacity)); + let storage_stats = Arc::new(CacheStatsHandler::new(storage_capacity)); + let account_stats = Arc::new(CacheStatsHandler::new(account_capacity)); + + Self { + code_cache: Arc::new( + FixedCache::new(code_capacity, FbBuildHasher::<32>::default()) + .with_stats(Some(Stats::new(code_stats.clone()))), + ), + storage_cache: Arc::new( + FixedCache::new(storage_capacity, DefaultHashBuilder::default()) + .with_stats(Some(Stats::new(storage_stats.clone()))), + ), + account_cache: Arc::new( + FixedCache::new(account_capacity, FbBuildHasher::<20>::default()) + .with_stats(Some(Stats::new(account_stats.clone()))), + ), + code_stats, + storage_stats, + account_stats, + selfdestruct_encountered: Arc::default(), + } + } + + /// Gets code from cache, or inserts using the provided function. + pub(crate) fn get_or_try_insert_code_with( + &self, + hash: B256, + f: impl FnOnce() -> Result, E>, + ) -> Result>, E> { + let mut miss = false; + let result = self.code_cache.get_or_try_insert_with(hash, |_| { + miss = true; + f() + })?; + + if miss { + Ok(CachedStatus::NotCached(result)) + } else { + Ok(CachedStatus::Cached(result)) + } + } + + /// Gets storage from cache, or inserts using the provided function. + pub(crate) fn get_or_try_insert_storage_with( + &self, + address: Address, + key: StorageKey, + f: impl FnOnce() -> Result, + ) -> Result, E> { + let mut miss = false; + let result = self.storage_cache.get_or_try_insert_with((address, key), |_| { + miss = true; + f() + })?; + + if miss { + Ok(CachedStatus::NotCached(result)) + } else { + Ok(CachedStatus::Cached(result)) + } + } + + /// Gets account from cache, or inserts using the provided function. + pub(crate) fn get_or_try_insert_account_with( + &self, + address: Address, + f: impl FnOnce() -> Result, E>, + ) -> Result>, E> { + let mut miss = false; + let result = self.account_cache.get_or_try_insert_with(address, |_| { + miss = true; + f() + })?; + + if miss { + Ok(CachedStatus::NotCached(result)) + } else { + Ok(CachedStatus::Cached(result)) + } + } + + /// Insert storage value into cache. pub(crate) fn insert_storage( &self, address: Address, key: StorageKey, value: Option, ) { - self.insert_storage_bulk(address, [(key, value)]); + self.storage_cache.insert((address, key), value.unwrap_or_default()); } - /// Insert multiple storage values into hierarchical cache for a single account - /// - /// This method is optimized for inserting multiple storage values for the same address - /// by doing the account cache lookup only once instead of for each key-value pair. - pub(crate) fn insert_storage_bulk(&self, address: Address, storage_entries: I) - where - I: IntoIterator)>, - { - let account_cache = self.storage_cache.get(&address).unwrap_or_default(); - - for (key, value) in storage_entries { - account_cache.insert_storage(key, value); - } - - // Insert to the cache so that moka picks up on the changed size, even though the actual - // value (the Arc) is the same - self.storage_cache.insert(address, account_cache); + /// Insert code into cache. + fn insert_code(&self, hash: B256, code: Option) { + self.code_cache.insert(hash, code); } - /// Inserts the [`AccountStorageCache`]. - pub(crate) fn insert_storage_cache( - &self, - address: Address, - storage_cache: Arc, - ) { - self.storage_cache.insert(address, storage_cache); - } - - /// Invalidate storage for specific account - pub(crate) fn invalidate_account_storage(&self, address: &Address) { - self.storage_cache.invalidate(address); - } - - /// Returns the total number of storage slots cached across all accounts - pub(crate) fn total_storage_slots(&self) -> usize { - self.storage_cache.iter().map(|addr| addr.len()).sum() + /// Insert account into cache. + fn insert_account(&self, address: Address, account: Option) { + self.account_cache.insert(address, account); } /// Inserts the post-execution state changes into the cache. @@ -448,7 +689,7 @@ impl ExecutionCache { .entered(); // Insert bytecodes for (code_hash, bytecode) in &state_updates.contracts { - self.code_cache.insert(*code_hash, Some(Bytecode(bytecode.clone()))); + self.insert_code(*code_hash, Some(Bytecode(bytecode.clone()))); } drop(_enter); @@ -467,12 +708,31 @@ impl ExecutionCache { continue } - // If the account was destroyed, invalidate from the account / storage caches + // If the original account had code (was a contract), we must clear the entire cache + // because we can't efficiently invalidate all storage slots for a single address. + // This should only happen on pre-Dencun networks. + // + // If the original account had no code (was an EOA or a not yet deployed contract), we + // just remove the account from cache - no storage exists for it. if account.was_destroyed() { - // Invalidate the account cache entry if destroyed - self.account_cache.invalidate(addr); + let had_code = + account.original_info.as_ref().is_some_and(|info| !info.is_empty_code_hash()); + if had_code { + self.selfdestruct_encountered.call_once(|| { + warn!( + target: "engine::caching", + address = ?addr, + info = ?account.info, + original_info = ?account.original_info, + "Encountered an inter-transaction SELFDESTRUCT that reset the storage cache. Are you running a pre-Dencun network?" + ); + }); + self.clear(); + return Ok(()) + } - self.invalidate_account_storage(addr); + self.account_cache.remove(addr); + self.account_stats.decrement_size(); continue } @@ -485,108 +745,47 @@ impl ExecutionCache { }; // Now we iterate over all storage and make updates to the cached storage values - // Use bulk insertion to optimize cache lookups - only lookup the account cache once - // instead of for each storage key - let storage_entries = account.storage.iter().map(|(storage_key, slot)| { - // We convert the storage key from U256 to B256 because that is how it's represented - // in the cache - ((*storage_key).into(), Some(slot.present_value)) - }); - self.insert_storage_bulk(*addr, storage_entries); + for (key, slot) in &account.storage { + self.insert_storage(*addr, (*key).into(), Some(slot.present_value)); + } // Insert will update if present, so we just use the new account info as the new value // for the account cache - self.account_cache.insert(*addr, Some(Account::from(account_info))); + self.insert_account(*addr, Some(Account::from(account_info))); } Ok(()) } -} -/// A builder for [`ExecutionCache`]. -#[derive(Debug)] -pub(crate) struct ExecutionCacheBuilder { - /// Code cache entries - code_cache_entries: u64, + /// Clears storage and account caches, resetting them to empty state. + /// + /// We do not clear the bytecodes cache, because its mapping can never change, as it's + /// `keccak256(bytecode) => bytecode`. + pub(crate) fn clear(&self) { + self.storage_cache.clear(); + self.account_cache.clear(); - /// Storage cache entries - storage_cache_entries: u64, - - /// Account cache entries - account_cache_entries: u64, -} - -impl ExecutionCacheBuilder { - /// Build an [`ExecutionCache`] struct, so that execution caches can be easily cloned. - pub(crate) fn build_caches(self, total_cache_size: u64) -> ExecutionCache { - let storage_cache_size = (total_cache_size * 8888) / 10000; // 88.88% of total - let account_cache_size = (total_cache_size * 556) / 10000; // 5.56% of total - let code_cache_size = (total_cache_size * 556) / 10000; // 5.56% of total - - const EXPIRY_TIME: Duration = Duration::from_secs(7200); // 2 hours - const TIME_TO_IDLE: Duration = Duration::from_secs(3600); // 1 hour - - let storage_cache = CacheBuilder::new(self.storage_cache_entries) - .weigher(|_key: &Address, value: &Arc| -> u32 { - // values based on results from measure_storage_cache_overhead test - let base_weight = 39_000; - let slots_weight = value.len() * 218; - (base_weight + slots_weight) as u32 - }) - .max_capacity(storage_cache_size) - .time_to_live(EXPIRY_TIME) - .time_to_idle(TIME_TO_IDLE) - .build_with_hasher(DefaultHashBuilder::default()); - - let account_cache = CacheBuilder::new(self.account_cache_entries) - .weigher(|_key: &Address, value: &Option| -> u32 { - // Account has a fixed size (none, balance,code_hash) - 20 + size_of_val(value) as u32 - }) - .max_capacity(account_cache_size) - .time_to_live(EXPIRY_TIME) - .time_to_idle(TIME_TO_IDLE) - .build_with_hasher(DefaultHashBuilder::default()); - - let code_cache = CacheBuilder::new(self.code_cache_entries) - .weigher(|_key: &B256, value: &Option| -> u32 { - let code_size = match value { - Some(bytecode) => { - // base weight + actual (padded) bytecode size + size of the jump table - (size_of_val(value) + - bytecode.bytecode().len() + - bytecode - .legacy_jump_table() - .map(|table| table.as_slice().len()) - .unwrap_or_default()) as u32 - } - None => size_of_val(value) as u32, - }; - 32 + code_size - }) - .max_capacity(code_cache_size) - .time_to_live(EXPIRY_TIME) - .time_to_idle(TIME_TO_IDLE) - .build_with_hasher(DefaultHashBuilder::default()); - - ExecutionCache { code_cache, storage_cache, account_cache } + self.storage_stats.reset_size(); + self.account_stats.reset_size(); } -} -impl Default for ExecutionCacheBuilder { - fn default() -> Self { - // With weigher and max_capacity in place, these numbers represent - // the maximum number of entries that can be stored, not the actual - // memory usage which is controlled by max_capacity. - // - // Code cache: up to 10M entries but limited to 0.5GB - // Storage cache: up to 10M accounts but limited to 8GB - // Account cache: up to 10M accounts but limited to 0.5GB - Self { - code_cache_entries: 10_000_000, - storage_cache_entries: 10_000_000, - account_cache_entries: 10_000_000, - } + /// Updates the provided metrics with the current stats from the cache's stats handlers, + /// and resets the hit/miss/collision counters. + pub(crate) fn update_metrics(&self, metrics: &CachedStateMetrics) { + metrics.code_cache_size.set(self.code_stats.size() as f64); + metrics.code_cache_capacity.set(self.code_stats.capacity() as f64); + metrics.code_cache_collisions.set(self.code_stats.collisions() as f64); + self.code_stats.reset_stats(); + + metrics.storage_cache_size.set(self.storage_stats.size() as f64); + metrics.storage_cache_capacity.set(self.storage_stats.capacity() as f64); + metrics.storage_cache_collisions.set(self.storage_stats.collisions() as f64); + self.storage_stats.reset_stats(); + + metrics.account_cache_size.set(self.account_stats.size() as f64); + metrics.account_cache_capacity.set(self.account_stats.capacity() as f64); + metrics.account_cache_collisions.set(self.account_stats.collisions() as f64); + self.account_stats.reset_stats(); } } @@ -600,7 +799,7 @@ pub(crate) struct SavedCache { /// The caches used for the provider. caches: ExecutionCache, - /// Metrics for the cached state provider + /// Metrics for the cached state provider (includes size/capacity/collisions from fixed-cache) metrics: CachedStateMetrics, /// A guard to track in-flight usage of this cache. @@ -653,17 +852,20 @@ impl SavedCache { &self.metrics } - /// Updates the metrics for the [`ExecutionCache`]. + /// Updates the cache metrics (size/capacity/collisions) from the stats handlers. /// - /// Note: This can be expensive with large cached state as it iterates over - /// all storage entries. Use `with_disable_cache_metrics(true)` to skip. + /// Note: This can be expensive with large cached state. Use + /// `with_disable_cache_metrics(true)` to skip. pub(crate) fn update_metrics(&self) { if self.disable_cache_metrics { - return; + return } - self.metrics.storage_cache_size.set(self.caches.total_storage_slots() as f64); - self.metrics.account_cache_size.set(self.caches.account_cache.entry_count() as f64); - self.metrics.code_cache_size.set(self.caches.code_cache.entry_count() as f64); + self.caches.update_metrics(&self.metrics); + } + + /// Clears all caches, resetting them to empty state. + pub(crate) fn clear(&self) { + self.caches.clear(); } } @@ -674,174 +876,27 @@ impl SavedCache { } } -/// Cache for an individual account's storage slots. -/// -/// This represents the second level of the hierarchical storage cache. -/// Each account gets its own `AccountStorageCache` to store accessed storage slots. -#[derive(Debug, Clone)] -pub(crate) struct AccountStorageCache { - /// Map of storage keys to their cached values. - slots: Cache>, -} - -impl AccountStorageCache { - /// Create a new [`AccountStorageCache`] - pub(crate) fn new(max_slots: u64) -> Self { - Self { - slots: CacheBuilder::new(max_slots).build_with_hasher(DefaultHashBuilder::default()), - } - } - - /// Get a storage value from this account's cache. - /// - `NotCached`: The slot is not in the cache - /// - `Empty`: The slot is empty - /// - `Value`: The slot has a specific value - pub(crate) fn get_storage(&self, key: &StorageKey) -> SlotStatus { - match self.slots.get(key) { - None => SlotStatus::NotCached, - Some(None) => SlotStatus::Empty, - Some(Some(value)) => SlotStatus::Value(value), - } - } - - /// Insert a storage value - pub(crate) fn insert_storage(&self, key: StorageKey, value: Option) { - self.slots.insert(key, value); - } - - /// Returns the number of slots in the cache - pub(crate) fn len(&self) -> usize { - self.slots.entry_count() as usize - } -} - -impl Default for AccountStorageCache { - fn default() -> Self { - // With weigher and max_capacity in place, this number represents - // the maximum number of entries that can be stored, not the actual - // memory usage which is controlled by storage cache's max_capacity. - Self::new(1_000_000) - } -} - #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{B256, U256}; - use rand::Rng; + use alloy_primitives::{map::HashMap, U256}; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; - use std::mem::size_of; - - mod tracking_allocator { - use std::{ - alloc::{GlobalAlloc, Layout, System}, - sync::atomic::{AtomicUsize, Ordering}, - }; - - #[derive(Debug)] - pub(crate) struct TrackingAllocator { - allocated: AtomicUsize, - total_allocated: AtomicUsize, - inner: System, - } - - impl TrackingAllocator { - pub(crate) const fn new() -> Self { - Self { - allocated: AtomicUsize::new(0), - total_allocated: AtomicUsize::new(0), - inner: System, - } - } - - pub(crate) fn reset(&self) { - self.allocated.store(0, Ordering::SeqCst); - self.total_allocated.store(0, Ordering::SeqCst); - } - - pub(crate) fn total_allocated(&self) -> usize { - self.total_allocated.load(Ordering::SeqCst) - } - } - - unsafe impl GlobalAlloc for TrackingAllocator { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - let ret = unsafe { self.inner.alloc(layout) }; - if !ret.is_null() { - self.allocated.fetch_add(layout.size(), Ordering::SeqCst); - self.total_allocated.fetch_add(layout.size(), Ordering::SeqCst); - } - ret - } - - unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - self.allocated.fetch_sub(layout.size(), Ordering::SeqCst); - unsafe { self.inner.dealloc(ptr, layout) } - } - } - } - - use tracking_allocator::TrackingAllocator; - - #[global_allocator] - static ALLOCATOR: TrackingAllocator = TrackingAllocator::new(); - - fn measure_allocation(f: F) -> (usize, T) - where - F: FnOnce() -> T, - { - ALLOCATOR.reset(); - let result = f(); - let total = ALLOCATOR.total_allocated(); - (total, result) - } - - #[test] - fn measure_storage_cache_overhead() { - let (base_overhead, cache) = measure_allocation(|| AccountStorageCache::new(1000)); - println!("Base AccountStorageCache overhead: {base_overhead} bytes"); - let mut rng = rand::rng(); - - let key = StorageKey::random(); - let value = StorageValue::from(rng.random::()); - let (first_slot, _) = measure_allocation(|| { - cache.insert_storage(key, Some(value)); - }); - println!("First slot insertion overhead: {first_slot} bytes"); - - const TOTAL_SLOTS: usize = 10_000; - let (test_slots, _) = measure_allocation(|| { - for _ in 0..TOTAL_SLOTS { - let key = StorageKey::random(); - let value = StorageValue::from(rng.random::()); - cache.insert_storage(key, Some(value)); - } - }); - println!("Average overhead over {} slots: {} bytes", TOTAL_SLOTS, test_slots / TOTAL_SLOTS); - - println!("\nTheoretical sizes:"); - println!("StorageKey size: {} bytes", size_of::()); - println!("StorageValue size: {} bytes", size_of::()); - println!("Option size: {} bytes", size_of::>()); - println!("Option size: {} bytes", size_of::>()); - } + use reth_revm::db::{AccountStatus, BundleAccount}; + use revm_state::AccountInfo; #[test] fn test_empty_storage_cached_state_provider() { - // make sure when we have an empty value in storage, we return `Empty` and not `NotCached` let address = Address::random(); let storage_key = StorageKey::random(); let account = ExtendedAccount::new(0, U256::ZERO); - // note there is no storage here let provider = MockEthProvider::default(); provider.extend_accounts(vec![(address, account)]); - let caches = ExecutionCacheBuilder::default().build_caches(1000); + let caches = ExecutionCache::new(1000); let state_provider = CachedStateProvider::new(provider, caches, CachedStateMetrics::zeroed()); - // check that the storage is empty let res = state_provider.storage(address, storage_key); assert!(res.is_ok()); assert_eq!(res.unwrap(), None); @@ -849,22 +904,19 @@ mod tests { #[test] fn test_uncached_storage_cached_state_provider() { - // make sure when we have something uncached, we get the cached value let address = Address::random(); let storage_key = StorageKey::random(); let storage_value = U256::from(1); let account = ExtendedAccount::new(0, U256::ZERO).extend_storage(vec![(storage_key, storage_value)]); - // note that we extend storage here with one value let provider = MockEthProvider::default(); provider.extend_accounts(vec![(address, account)]); - let caches = ExecutionCacheBuilder::default().build_caches(1000); + let caches = ExecutionCache::new(1000); let state_provider = CachedStateProvider::new(provider, caches, CachedStateMetrics::zeroed()); - // check that the storage returns the expected value let res = state_provider.storage(address, storage_key); assert!(res.is_ok()); assert_eq!(res.unwrap(), Some(storage_value)); @@ -872,88 +924,191 @@ mod tests { #[test] fn test_get_storage_populated() { - // make sure when we have something cached, we get the cached value in the `SlotStatus` let address = Address::random(); let storage_key = StorageKey::random(); let storage_value = U256::from(1); - // insert into caches directly - let caches = ExecutionCacheBuilder::default().build_caches(1000); + let caches = ExecutionCache::new(1000); caches.insert_storage(address, storage_key, Some(storage_value)); - // check that the storage returns the cached value - let (slot_status, _) = caches.get_storage(&address, &storage_key); - assert_eq!(slot_status, SlotStatus::Value(storage_value)); - } - - #[test] - fn test_get_storage_not_cached() { - // make sure when we have nothing cached, we get the `NotCached` value in the `SlotStatus` - let storage_key = StorageKey::random(); - let address = Address::random(); - - // just create empty caches - let caches = ExecutionCacheBuilder::default().build_caches(1000); - - // check that the storage is not cached - let (slot_status, _) = caches.get_storage(&address, &storage_key); - assert_eq!(slot_status, SlotStatus::NotCached); + let result = caches + .get_or_try_insert_storage_with(address, storage_key, || Ok::<_, ()>(U256::from(999))); + assert_eq!(result.unwrap(), CachedStatus::Cached(storage_value)); } #[test] fn test_get_storage_empty() { - // make sure when we insert an empty value to the cache, we get the `Empty` value in the - // `SlotStatus` let address = Address::random(); let storage_key = StorageKey::random(); - // insert into caches directly - let caches = ExecutionCacheBuilder::default().build_caches(1000); + let caches = ExecutionCache::new(1000); caches.insert_storage(address, storage_key, None); - // check that the storage is empty - let (slot_status, _) = caches.get_storage(&address, &storage_key); - assert_eq!(slot_status, SlotStatus::Empty); + let result = caches + .get_or_try_insert_storage_with(address, storage_key, || Ok::<_, ()>(U256::from(999))); + assert_eq!(result.unwrap(), CachedStatus::Cached(U256::ZERO)); } - // Tests for SavedCache locking mechanism #[test] fn test_saved_cache_is_available() { - let execution_cache = ExecutionCacheBuilder::default().build_caches(1000); + let execution_cache = ExecutionCache::new(1000); let cache = SavedCache::new(B256::ZERO, execution_cache, CachedStateMetrics::zeroed()); - // Initially, the cache should be available (only one reference) assert!(cache.is_available(), "Cache should be available initially"); - // Clone the usage guard (simulating it being handed out) let _guard = cache.clone_guard_for_test(); - // Now the cache should not be available (two references) assert!(!cache.is_available(), "Cache should not be available with active guard"); } #[test] fn test_saved_cache_multiple_references() { - let execution_cache = ExecutionCacheBuilder::default().build_caches(1000); + let execution_cache = ExecutionCache::new(1000); let cache = SavedCache::new(B256::from([2u8; 32]), execution_cache, CachedStateMetrics::zeroed()); - // Create multiple references to the usage guard let guard1 = cache.clone_guard_for_test(); let guard2 = cache.clone_guard_for_test(); let guard3 = guard1.clone(); - // Cache should not be available with multiple guards assert!(!cache.is_available()); - // Drop guards one by one drop(guard1); - assert!(!cache.is_available()); // Still not available + assert!(!cache.is_available()); drop(guard2); - assert!(!cache.is_available()); // Still not available + assert!(!cache.is_available()); drop(guard3); - assert!(cache.is_available()); // Now available + assert!(cache.is_available()); + } + + #[test] + fn test_insert_state_destroyed_account_with_code_clears_cache() { + let caches = ExecutionCache::new(1000); + + // Pre-populate caches with some data + let addr1 = Address::random(); + let addr2 = Address::random(); + let storage_key = StorageKey::random(); + caches.insert_account(addr1, Some(Account::default())); + caches.insert_account(addr2, Some(Account::default())); + caches.insert_storage(addr1, storage_key, Some(U256::from(42))); + + // Verify caches are populated + assert!(caches.account_cache.get(&addr1).is_some()); + assert!(caches.account_cache.get(&addr2).is_some()); + assert!(caches.storage_cache.get(&(addr1, storage_key)).is_some()); + + let bundle = BundleState { + // BundleState with a destroyed contract (had code) + state: HashMap::from_iter([( + Address::random(), + BundleAccount::new( + Some(AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: B256::random(), // Non-empty code hash + code: None, + account_id: None, + }), + None, // Destroyed, so no current info + Default::default(), + AccountStatus::Destroyed, + ), + )]), + contracts: Default::default(), + reverts: Default::default(), + state_size: 0, + reverts_size: 0, + }; + + // Insert state should clear all caches because a contract was destroyed + let result = caches.insert_state(&bundle); + assert!(result.is_ok()); + + // Verify all caches were cleared + assert!(caches.account_cache.get(&addr1).is_none()); + assert!(caches.account_cache.get(&addr2).is_none()); + assert!(caches.storage_cache.get(&(addr1, storage_key)).is_none()); + } + + #[test] + fn test_insert_state_destroyed_account_without_code_removes_only_account() { + let caches = ExecutionCache::new(1000); + + // Pre-populate caches with some data + let addr1 = Address::random(); + let addr2 = Address::random(); + let storage_key = StorageKey::random(); + caches.insert_account(addr1, Some(Account::default())); + caches.insert_account(addr2, Some(Account::default())); + caches.insert_storage(addr1, storage_key, Some(U256::from(42))); + + let bundle = BundleState { + // BundleState with a destroyed EOA (no code) + state: HashMap::from_iter([( + addr1, + BundleAccount::new( + Some(AccountInfo { + balance: U256::from(100), + nonce: 1, + code_hash: alloy_primitives::KECCAK256_EMPTY, // Empty code hash = EOA + code: None, + account_id: None, + }), + None, // Destroyed + Default::default(), + AccountStatus::Destroyed, + ), + )]), + contracts: Default::default(), + reverts: Default::default(), + state_size: 0, + reverts_size: 0, + }; + + // Insert state should only remove the destroyed account + assert!(caches.insert_state(&bundle).is_ok()); + + // Verify only addr1 was removed, other data is still present + assert!(caches.account_cache.get(&addr1).is_none()); + assert!(caches.account_cache.get(&addr2).is_some()); + assert!(caches.storage_cache.get(&(addr1, storage_key)).is_some()); + } + + #[test] + fn test_insert_state_destroyed_account_no_original_info_removes_only_account() { + let caches = ExecutionCache::new(1000); + + // Pre-populate caches + let addr1 = Address::random(); + let addr2 = Address::random(); + caches.insert_account(addr1, Some(Account::default())); + caches.insert_account(addr2, Some(Account::default())); + + let bundle = BundleState { + // BundleState with a destroyed account (has no original info) + state: HashMap::from_iter([( + addr1, + BundleAccount::new( + None, // No original info + None, // Destroyed + Default::default(), + AccountStatus::Destroyed, + ), + )]), + contracts: Default::default(), + reverts: Default::default(), + state_size: 0, + reverts_size: 0, + }; + + // Insert state should only remove the destroyed account (no code = no full clear) + assert!(caches.insert_state(&bundle).is_ok()); + + // Verify only addr1 was removed + assert!(caches.account_cache.get(&addr1).is_none()); + assert!(caches.account_cache.get(&addr2).is_some()); } } diff --git a/crates/engine/tree/src/tree/payload_processor/mod.rs b/crates/engine/tree/src/tree/payload_processor/mod.rs index 23da7c23cc..24af914873 100644 --- a/crates/engine/tree/src/tree/payload_processor/mod.rs +++ b/crates/engine/tree/src/tree/payload_processor/mod.rs @@ -2,10 +2,7 @@ use super::precompile_cache::PrecompileCacheMap; use crate::tree::{ - cached_state::{ - CachedStateMetrics, CachedStateProvider, ExecutionCache as StateExecutionCache, - ExecutionCacheBuilder, SavedCache, - }, + cached_state::{CachedStateMetrics, CachedStateProvider, ExecutionCache, SavedCache}, payload_processor::{ prewarm::{PrewarmCacheTask, PrewarmContext, PrewarmMode, PrewarmTaskEvent}, sparse_trie::StateRootComputeOutcome, @@ -116,11 +113,11 @@ where /// The executor used by to spawn tasks. executor: WorkloadExecutor, /// The most recent cache used for execution. - execution_cache: ExecutionCache, + execution_cache: PayloadExecutionCache, /// Metrics for trie operations trie_metrics: MultiProofTaskMetrics, /// Cross-block cache size in bytes. - cross_block_cache_size: u64, + cross_block_cache_size: usize, /// Whether transactions should not be executed on prewarming task. disable_transaction_prewarming: bool, /// Whether state cache should be disable @@ -313,7 +310,7 @@ where // Build a state provider for the multiproof task let provider = provider_builder.build().expect("failed to build provider"); let provider = if let Some(saved_cache) = saved_cache { - let (cache, metrics, _) = saved_cache.split(); + let (cache, metrics, _disable_metrics) = saved_cache.split(); Box::new(CachedStateProvider::new(provider, cache, metrics)) as Box } else { @@ -495,8 +492,11 @@ where cache } else { debug!("creating new execution cache on cache miss"); - let cache = ExecutionCacheBuilder::default().build_caches(self.cross_block_cache_size); - SavedCache::new(parent_hash, cache, CachedStateMetrics::zeroed()) + let start = Instant::now(); + let cache = ExecutionCache::new(self.cross_block_cache_size); + let metrics = CachedStateMetrics::zeroed(); + metrics.record_cache_creation(start.elapsed()); + SavedCache::new(parent_hash, cache, metrics) .with_disable_cache_metrics(self.disable_cache_metrics) } } @@ -587,28 +587,27 @@ where parent_hash = %block_with_parent.parent, "Cannot find cache for parent hash, skip updating cache with new state for inserted executed block", ); - return; + return } // Take existing cache (if any) or create fresh caches - let (caches, cache_metrics) = match cached.take() { - Some(existing) => { - let (c, m, _) = existing.split(); - (c, m) - } + let (caches, cache_metrics, _) = match cached.take() { + Some(existing) => existing.split(), None => ( - ExecutionCacheBuilder::default().build_caches(self.cross_block_cache_size), + ExecutionCache::new(self.cross_block_cache_size), CachedStateMetrics::zeroed(), + false, ), }; // Insert the block's bundle state into cache - let new_cache = SavedCache::new(block_with_parent.block.hash, caches, cache_metrics) - .with_disable_cache_metrics(disable_cache_metrics); + let new_cache = + SavedCache::new(block_with_parent.block.hash, caches, cache_metrics) + .with_disable_cache_metrics(disable_cache_metrics); if new_cache.cache().insert_state(bundle_state).is_err() { *cached = None; debug!(target: "engine::caching", "cleared execution cache on update error"); - return; + return } new_cache.update_metrics(); @@ -672,7 +671,7 @@ impl PayloadHandle { } /// Returns a clone of the caches used by prewarming - pub(super) fn caches(&self) -> Option { + pub(super) fn caches(&self) -> Option { self.prewarm_handle.saved_cache.as_ref().map(|cache| cache.cache().clone()) } @@ -776,29 +775,29 @@ impl Drop for CacheTaskHandle { /// ## Cache Safety /// /// **CRITICAL**: Cache update operations require exclusive access. All concurrent cache users -/// (such as prewarming tasks) must be terminated before calling `update_with_guard`, otherwise -/// the cache may be corrupted or cleared. +/// (such as prewarming tasks) must be terminated before calling +/// [`PayloadExecutionCache::update_with_guard`], otherwise the cache may be corrupted or cleared. /// /// ## Cache vs Prewarming Distinction /// -/// **`ExecutionCache`**: +/// **[`PayloadExecutionCache`]**: /// - Stores parent block's execution state after completion /// - Used to fetch parent data for next block's execution /// - Must be exclusively accessed during save operations /// -/// **`PrewarmCacheTask`**: +/// **[`PrewarmCacheTask`]**: /// - Speculatively loads accounts/storage that might be used in transaction execution /// - Prepares data for state root proof computation /// - Runs concurrently but must not interfere with cache saves #[derive(Clone, Debug, Default)] -struct ExecutionCache { +struct PayloadExecutionCache { /// Guarded cloneable cache identified by a block hash. inner: Arc>>, /// Metrics for cache operations. metrics: ExecutionCacheMetrics, } -impl ExecutionCache { +impl PayloadExecutionCache { /// Returns the cache for `parent_hash` if it's available for use. /// /// A cache is considered available when: @@ -834,11 +833,15 @@ impl ExecutionCache { "Existing cache found" ); - if hash_matches && available { - return Some(c.clone()); - } - - if hash_matches && !available { + if available { + // If the has is available (no other threads are using it), but has a mismatching + // parent hash, we can just clear it and keep using without re-creating from + // scratch. + if !hash_matches { + c.clear(); + } + return Some(c.clone()) + } else if hash_matches { self.metrics.execution_cache_in_use.increment(1); } } else { @@ -911,9 +914,9 @@ where #[cfg(test)] mod tests { - use super::ExecutionCache; + use super::PayloadExecutionCache; use crate::tree::{ - cached_state::{CachedStateMetrics, ExecutionCacheBuilder, SavedCache}, + cached_state::{CachedStateMetrics, ExecutionCache, SavedCache}, payload_processor::{ evm_state_to_hashed_post_state, executor::WorkloadExecutor, PayloadProcessor, }, @@ -943,13 +946,13 @@ mod tests { use std::sync::Arc; fn make_saved_cache(hash: B256) -> SavedCache { - let execution_cache = ExecutionCacheBuilder::default().build_caches(1_000); + let execution_cache = ExecutionCache::new(1_000); SavedCache::new(hash, execution_cache, CachedStateMetrics::zeroed()) } #[test] fn execution_cache_allows_single_checkout() { - let execution_cache = ExecutionCache::default(); + let execution_cache = PayloadExecutionCache::default(); let hash = B256::from([1u8; 32]); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash))); @@ -968,7 +971,7 @@ mod tests { #[test] fn execution_cache_checkout_releases_on_drop() { - let execution_cache = ExecutionCache::default(); + let execution_cache = PayloadExecutionCache::default(); let hash = B256::from([2u8; 32]); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash))); @@ -984,19 +987,21 @@ mod tests { } #[test] - fn execution_cache_mismatch_parent_returns_none() { - let execution_cache = ExecutionCache::default(); + fn execution_cache_mismatch_parent_clears_and_returns() { + let execution_cache = PayloadExecutionCache::default(); let hash = B256::from([3u8; 32]); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash))); - let miss = execution_cache.get_cache_for(B256::from([4u8; 32])); - assert!(miss.is_none(), "checkout should fail for different parent hash"); + // When the parent hash doesn't match, the cache is cleared and returned for reuse + let different_hash = B256::from([4u8; 32]); + let cache = execution_cache.get_cache_for(different_hash); + assert!(cache.is_some(), "cache should be returned for reuse after clearing") } #[test] fn execution_cache_update_after_release_succeeds() { - let execution_cache = ExecutionCache::default(); + let execution_cache = PayloadExecutionCache::default(); let initial = B256::from([5u8; 32]); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(initial))); diff --git a/crates/engine/tree/src/tree/payload_processor/multiproof.rs b/crates/engine/tree/src/tree/payload_processor/multiproof.rs index 12514e2dc9..472ea08a6c 100644 --- a/crates/engine/tree/src/tree/payload_processor/multiproof.rs +++ b/crates/engine/tree/src/tree/payload_processor/multiproof.rs @@ -1516,8 +1516,9 @@ where #[cfg(test)] mod tests { + use crate::tree::cached_state::CachedStateProvider; + use super::*; - use crate::tree::cached_state::{CachedStateProvider, ExecutionCacheBuilder}; use alloy_eip7928::{AccountChanges, BalanceChange}; use alloy_primitives::Address; use reth_provider::{ @@ -1577,7 +1578,7 @@ mod tests { { let db_provider = factory.database_provider_ro().unwrap(); let state_provider: StateProviderBox = Box::new(LatestStateProvider::new(db_provider)); - let cache = ExecutionCacheBuilder::default().build_caches(1000); + let cache = crate::tree::cached_state::ExecutionCache::new(1000); CachedStateProvider::new(state_provider, cache, Default::default()) } diff --git a/crates/engine/tree/src/tree/payload_processor/prewarm.rs b/crates/engine/tree/src/tree/payload_processor/prewarm.rs index 5c782ed1f5..e68342112a 100644 --- a/crates/engine/tree/src/tree/payload_processor/prewarm.rs +++ b/crates/engine/tree/src/tree/payload_processor/prewarm.rs @@ -17,7 +17,7 @@ use crate::tree::{ bal::{total_slots, BALSlotIter}, executor::WorkloadExecutor, multiproof::{MultiProofMessage, VersionedMultiProofTargets}, - ExecutionCache as PayloadExecutionCache, + PayloadExecutionCache, }, precompile_cache::{CachedPrecompile, PrecompileCacheMap}, ExecutionEnv, StateProviderBuilder, diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index d7c320fc52..bd16e3b359 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -24,7 +24,7 @@ pub struct DefaultEngineValues { prewarming_disabled: bool, parallel_sparse_trie_disabled: bool, state_provider_metrics: bool, - cross_block_cache_size: u64, + cross_block_cache_size: usize, state_root_task_compare_updates: bool, accept_execution_requests_hash: bool, multiproof_chunking_enabled: bool, @@ -94,7 +94,7 @@ impl DefaultEngineValues { } /// Set the default cross-block cache size in MB - pub const fn with_cross_block_cache_size(mut self, v: u64) -> Self { + pub const fn with_cross_block_cache_size(mut self, v: usize) -> Self { self.cross_block_cache_size = v; self } @@ -262,7 +262,7 @@ pub struct EngineArgs { /// Configure the size of cross-block cache in megabytes #[arg(long = "engine.cross-block-cache-size", default_value_t = DefaultEngineValues::get_global().cross_block_cache_size)] - pub cross_block_cache_size: u64, + pub cross_block_cache_size: usize, /// Enable comparing trie updates from the state root task to the trie updates from the regular /// state root calculation. diff --git a/crates/node/core/src/node_config.rs b/crates/node/core/src/node_config.rs index 225c957c1c..5d4d8cfe52 100644 --- a/crates/node/core/src/node_config.rs +++ b/crates/node/core/src/node_config.rs @@ -39,7 +39,7 @@ pub use reth_engine_primitives::{ }; /// Default size of cross-block cache in megabytes. -pub const DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB: u64 = 4 * 1024; +pub const DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB: usize = 4 * 1024; /// This includes all necessary configuration to launch the node. /// The individual configuration options can be overwritten before launching the node.