diff --git a/CHANGELOG.md b/CHANGELOG.md index 11dcc9e..7cb85c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,16 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). exactly like a freshly-built overlay. The 64 KB shared-memory buffer is also recycled across the build→transact→revert call methods (stored as a plain `Vec`, so the overlay stays `Send`). +- **Configurable EVM shared-memory pre-allocation** — `SharedMemoryCapacity` + (`Fixed(usize)` / `Auto`, default `Fixed(64_000)`) set via + `EvmCacheBuilder::shared_memory_capacity`. `Fixed` pins the per-context working- + memory buffer (general users running wide fan-outs of small simulations can lower + it to cut per-overlay memory; the previous behavior is the default); `Auto` sizes + it from the chain state loaded at build time (e.g. a bincode state file), clamped + to a 64 kB floor / 4 MiB ceiling. The resolved size is readable via + `EvmCache::shared_memory_capacity()` and is propagated to every snapshot so + snapshot-backed overlays pre-allocate the same amount. `with_cache_capacity` is + the lower-level constructor behind the builder setter. ### Changed diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 9a1e2d9..53ae869 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -300,6 +300,7 @@ pub struct EvmCacheBuilder

{ block: Option, cache_config: Option, spec_id: SpecId, + shared_memory_capacity: SharedMemoryCapacity, } impl

EvmCacheBuilder

@@ -313,6 +314,7 @@ where block: None, cache_config: None, spec_id: SpecId::CANCUN, + shared_memory_capacity: SharedMemoryCapacity::default(), } } @@ -354,9 +356,28 @@ where self } + /// Set how much EVM shared memory to pre-allocate per simulation context. + /// + /// Defaults to [`SharedMemoryCapacity::Fixed`]`(64_000)` (today's behavior). + /// Use `Fixed(n)` to pin a size, or [`SharedMemoryCapacity::Auto`] to size it + /// from the chain state loaded at [`build`](Self::build) time (e.g. a bincode + /// state file supplied via [`cache_config`](Self::cache_config)). See + /// [`SharedMemoryCapacity`] for the trade-offs. + pub fn shared_memory_capacity(mut self, capacity: SharedMemoryCapacity) -> Self { + self.shared_memory_capacity = capacity; + self + } + /// Build the [`EvmCache`], fetching the pinned block's header for context. pub async fn build(self) -> EvmCache { - EvmCache::with_cache(self.provider, self.block, self.cache_config, self.spec_id).await + EvmCache::with_cache_capacity( + self.provider, + self.block, + self.cache_config, + self.spec_id, + self.shared_memory_capacity, + ) + .await } } @@ -368,10 +389,67 @@ type InspectorCacheEvm<'a, INSP> = revm::MainnetEvm< INSP, >; -/// Default initial capacity for shared memory buffer. -/// Set to 64KB based on profiling (16x the REVM default of 4KB). -/// This eliminates reallocation during typical simulations with headroom. -const DEFAULT_SHARED_MEMORY_CAPACITY: usize = 64 * 1024; +/// Default initial capacity for the EVM shared-memory (working-memory) buffer. +/// 64 kB, chosen from profiling a state-heavy workload (16x the revm default of +/// 4 kB) so simulations rarely reallocate. Exposed for tuning via +/// [`SharedMemoryCapacity`]. +const DEFAULT_SHARED_MEMORY_CAPACITY: usize = 64_000; + +/// How much EVM shared memory (per-context working memory) to pre-allocate for +/// simulations. +/// +/// revm grows its shared memory on demand during execution; pre-allocating just +/// avoids repeated reallocations when simulations touch a lot of memory — the +/// original motivation was a state-heavy workload where resizing was hot. The +/// trade-off cuts both ways: a wide parallel fan-out of *small* simulations pays +/// this much memory per overlay, so general users may want a smaller `Fixed` size, +/// while state-heavy users can raise it or let it auto-size from the loaded state. +/// +/// The default is `Fixed(64_000)` (today's behavior). Configure it on +/// [`EvmCacheBuilder::shared_memory_capacity`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SharedMemoryCapacity { + /// Pre-allocate exactly this many bytes. The [`Default`] is `Fixed(64_000)`. + Fixed(usize), + /// Size the buffer from the amount of chain state loaded into the cache at + /// construction (e.g. from a bincode state file via + /// [`CacheConfig`]/[`EvmCacheBuilder::cache_config`]), clamped to a sane + /// floor/ceiling. Falls back to the floor when nothing is loaded. + /// + /// This is a heuristic proxy — persisted state size loosely correlates with the + /// working-set size of simulations over it, not an exact peak-memory model. Use + /// `Fixed` when you have profiled your workload. + Auto, +} + +impl Default for SharedMemoryCapacity { + fn default() -> Self { + Self::Fixed(DEFAULT_SHARED_MEMORY_CAPACITY) + } +} + +impl SharedMemoryCapacity { + /// Floor for [`Auto`](Self::Auto) (and the default fixed size): 64 kB. + pub const MIN_AUTO: usize = DEFAULT_SHARED_MEMORY_CAPACITY; + /// Ceiling for [`Auto`](Self::Auto): 4 MiB. A simulation that needs more than + /// this still works — revm grows the buffer past it on demand. + pub const MAX_AUTO: usize = 4 * 1024 * 1024; + /// Heuristic proxy: bytes of pre-allocated working memory per loaded storage + /// slot. Tune if profiling warrants. + const AUTO_BYTES_PER_SLOT: usize = 16; + + /// Resolve to a concrete byte capacity. `loaded_slots` is the number of layer-2 + /// storage slots present in the cache at construction (0 when nothing is + /// loaded); it is consulted only for [`Auto`](Self::Auto). + pub(crate) fn resolve(self, loaded_slots: usize) -> usize { + match self { + Self::Fixed(bytes) => bytes, + Self::Auto => loaded_slots + .saturating_mul(Self::AUTO_BYTES_PER_SLOT) + .clamp(Self::MIN_AUTO, Self::MAX_AUTO), + } + } +} /// EVM cache with lazy-loading RPC backend. /// @@ -452,6 +530,12 @@ pub struct EvmCache { /// uncontrolled lazy-fetch growth that bypasses the write funnel. Not /// serialized. base_storage_lens: HashMap, + /// Resolved per-context EVM shared-memory pre-allocation (bytes), from the + /// [`SharedMemoryCapacity`] at construction (resolving `Auto` against the loaded + /// state). Propagated to each [`EvmSnapshot`] so snapshot-backed overlays + /// pre-allocate the same amount. See + /// [`shared_memory_capacity`](Self::shared_memory_capacity). + shared_memory_capacity: usize, } /// Outcome of a balance-delta-tracking simulation. @@ -586,6 +670,31 @@ impl EvmCache { cache_config: Option, spec_id: SpecId, ) -> Self + where + P: Provider + 'static, + { + Self::with_cache_capacity( + provider, + block, + cache_config, + spec_id, + SharedMemoryCapacity::default(), + ) + .await + } + + /// Like [`with_cache`](Self::with_cache) but takes an explicit + /// [`SharedMemoryCapacity`] controlling per-context EVM working-memory + /// pre-allocation. This is what [`EvmCacheBuilder::build`] calls; prefer the + /// builder. With [`SharedMemoryCapacity::Auto`] the buffer is sized from the + /// layer-2 storage loaded at construction (e.g. a bincode state file). + pub async fn with_cache_capacity

( + provider: Arc

, + block: Option, + cache_config: Option, + spec_id: SpecId, + shared_memory_capacity: SharedMemoryCapacity, + ) -> Self where P: Provider + 'static, { @@ -904,6 +1013,20 @@ impl EvmCache { // Extract chain_id from cache config if available, default to Arbitrum let chain_id = cache_config.as_ref().map(|c| c.chain_id).unwrap_or(42161); + // Resolve the shared-memory pre-allocation. For `Auto` we size from the + // amount of layer-2 chain state actually loaded (post-filter), so a large + // bincode state file yields a larger buffer; `Fixed` ignores the count. + let loaded_slots = match shared_memory_capacity { + SharedMemoryCapacity::Auto => blockchain_db + .storage() + .read() + .values() + .map(|s| s.len()) + .sum(), + SharedMemoryCapacity::Fixed(_) => 0, + }; + let shared_memory_capacity = shared_memory_capacity.resolve(loaded_slots); + Self { backend, blockchain_db, @@ -921,9 +1044,7 @@ impl EvmCache { coinbase, prevrandao, block_gas_limit, - shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity( - DEFAULT_SHARED_MEMORY_CAPACITY, - ))), + shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity(shared_memory_capacity))), rpc_caller: Some(rpc_caller), storage_batch_fetcher: Some(storage_batch_fetcher), batch_block_id, @@ -933,6 +1054,7 @@ impl EvmCache { base_dirty: HashSet::new(), base_full_rebuild: false, base_storage_lens: HashMap::new(), + shared_memory_capacity, } } @@ -1011,6 +1133,7 @@ impl EvmCache { base_dirty: HashSet::new(), base_full_rebuild: false, base_storage_lens: HashMap::new(), + shared_memory_capacity: DEFAULT_SHARED_MEMORY_CAPACITY, } } @@ -2147,6 +2270,7 @@ impl EvmCache { chain_id: self.chain_id, timestamp: self.timestamp_override, spec_id: self.spec_id, + shared_memory_capacity: self.shared_memory_capacity, }) } @@ -2416,6 +2540,7 @@ impl EvmCache { chain_id: self.chain_id, timestamp: self.timestamp_override, spec_id: self.spec_id, + shared_memory_capacity: self.shared_memory_capacity, }) } @@ -3275,6 +3400,22 @@ impl EvmCache { "Reserved shared memory buffer capacity" ); } + drop(buffer); + // Record the high-water mark so snapshots taken afterwards propagate it to + // their overlays (snapshots copy the capacity at creation time). + self.shared_memory_capacity = self.shared_memory_capacity.max(capacity); + } + + /// The resolved per-context EVM shared-memory pre-allocation, in bytes. + /// + /// This is the [`SharedMemoryCapacity`] configured on the + /// [`EvmCacheBuilder`] resolved to a concrete size (with + /// [`SharedMemoryCapacity::Auto`] resolved against the state loaded at + /// construction), raised by any later [`reserve_shared_memory`](Self::reserve_shared_memory). + /// Each [`create_snapshot`](Self::create_snapshot) copies it onto the snapshot + /// so snapshot-backed [`EvmOverlay`]s pre-allocate the same amount. + pub fn shared_memory_capacity(&self) -> usize { + self.shared_memory_capacity } /// Purge all storage slots for a specific pool from both cache layers. @@ -4182,6 +4323,35 @@ fn extract_access_list(state: &revm::state::EvmState) -> AccessList { AccessList(items) } +#[cfg(test)] +mod shared_memory_capacity_tests { + use super::SharedMemoryCapacity as Cap; + + #[test] + fn default_is_fixed_64k() { + assert_eq!(Cap::default(), Cap::Fixed(64_000)); + } + + #[test] + fn fixed_ignores_loaded_slots() { + assert_eq!(Cap::Fixed(8_192).resolve(10_000_000), 8_192); + assert_eq!(Cap::Fixed(0).resolve(123), 0); + } + + #[test] + fn auto_floors_clamps_and_scales() { + // Nothing / little loaded → floor. + assert_eq!(Cap::Auto.resolve(0), Cap::MIN_AUTO); + assert_eq!(Cap::Auto.resolve(1_000), Cap::MIN_AUTO); // 16 KB < 64 KB floor + // Linear region (16 bytes/slot). + assert_eq!(Cap::Auto.resolve(10_000), 160_000); + assert_eq!(Cap::Auto.resolve(100_000), 1_600_000); + // Ceiling. + assert_eq!(Cap::Auto.resolve(usize::MAX), Cap::MAX_AUTO); + assert_eq!(Cap::Auto.resolve(262_144), Cap::MAX_AUTO); // 262_144 * 16 == 4 MiB + } +} + #[cfg(all(test, feature = "protocols"))] mod tests { use super::*; diff --git a/src/cache/overlay.rs b/src/cache/overlay.rs index 4e3fe38..2d84fe2 100644 --- a/src/cache/overlay.rs +++ b/src/cache/overlay.rs @@ -21,9 +21,6 @@ use crate::access_set::StorageAccessList; use crate::errors::{SimError, SimulationError, SimulationResult}; use crate::inspector::TransferInspector; -/// Default initial capacity for shared memory buffer (64KB). -const OVERLAY_SHARED_MEMORY_CAPACITY: usize = 64 * 1024; - type OverlayEvm<'a> = revm::MainnetEvm< Context, ()>, >; @@ -63,17 +60,28 @@ pub struct EvmOverlay { /// for revm's [`LocalContext`], runs, then reclaims and clears it after the /// EVM is dropped (see [`Self::build_evm_with_local`]). reusable_buffer: Vec, + /// Target pre-allocation (bytes) for [`Self::reusable_buffer`] and each + /// per-call buffer, taken from the snapshot's configured + /// [`SharedMemoryCapacity`](super::SharedMemoryCapacity) so overlays honor the + /// capacity set on the originating [`EvmCache`]. + buffer_capacity: usize, } impl EvmOverlay { /// Create a new overlay on the given snapshot. + /// + /// The reusable shared-memory buffer is pre-allocated to the snapshot's + /// configured shared-memory capacity (see + /// [`SharedMemoryCapacity`](super::SharedMemoryCapacity)). pub fn new(snapshot: Arc, ext_db: Option) -> Self { + let buffer_capacity = snapshot.shared_memory_capacity; Self { snapshot, dirty_accounts: HashMap::new(), dirty_storage: HashMap::new(), ext_db, - reusable_buffer: Vec::with_capacity(OVERLAY_SHARED_MEMORY_CAPACITY), + reusable_buffer: Vec::with_capacity(buffer_capacity), + buffer_capacity, } } @@ -133,11 +141,9 @@ impl EvmOverlay { /// Used by the public [`Self::build_evm`], which hands out the EVM and cannot /// reclaim its buffer afterwards. The internal call methods instead recycle /// [`Self::reusable_buffer`] via [`Self::build_evm_with_local`]. - fn fresh_local() -> LocalContext { + fn fresh_local(&self) -> LocalContext { LocalContext { - shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity( - OVERLAY_SHARED_MEMORY_CAPACITY, - ))), + shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity(self.buffer_capacity))), precompile_error_message: None, } } @@ -212,7 +218,7 @@ impl EvmOverlay { /// Note: The returned EVM is `!Send` (due to `LocalContext`'s `Rc`), /// but this is fine because it's created and used within a single task. pub fn build_evm(&mut self) -> OverlayEvm<'_> { - let local = Self::fresh_local(); + let local = self.fresh_local(); self.build_evm_with_local(local) } @@ -300,7 +306,7 @@ impl EvmOverlay { buf.clear(); self.reusable_buffer = buf; } else { - self.reusable_buffer = Vec::with_capacity(OVERLAY_SHARED_MEMORY_CAPACITY); + self.reusable_buffer = Vec::with_capacity(self.buffer_capacity); } } @@ -805,6 +811,7 @@ mod tests { chain_id: 42161, timestamp: None, spec_id: SpecId::CANCUN, + shared_memory_capacity: 64_000, }) } diff --git a/src/cache/snapshot.rs b/src/cache/snapshot.rs index 3e3e50f..0b6c5ec 100644 --- a/src/cache/snapshot.rs +++ b/src/cache/snapshot.rs @@ -112,6 +112,12 @@ pub struct EvmSnapshot { pub(crate) chain_id: u64, pub(crate) timestamp: Option, pub(crate) spec_id: SpecId, + /// Per-context EVM shared-memory pre-allocation (bytes) copied from the + /// [`EvmCache`](super::EvmCache) at snapshot time, so an [`EvmOverlay`] built + /// from this snapshot pre-allocates the same working-memory size the live cache + /// was configured with (see + /// [`SharedMemoryCapacity`](super::SharedMemoryCapacity)). + pub(crate) shared_memory_capacity: usize, } impl EvmSnapshot { @@ -202,6 +208,7 @@ mod tests { chain_id: 42161, timestamp: None, spec_id: SpecId::CANCUN, + shared_memory_capacity: 64_000, }; assert_eq!(snap.chain_id, 42161); assert_eq!(snap.block_number, Some(100)); diff --git a/tests/shared_memory_capacity.rs b/tests/shared_memory_capacity.rs new file mode 100644 index 0000000..08a9bf4 --- /dev/null +++ b/tests/shared_memory_capacity.rs @@ -0,0 +1,114 @@ +//! Offline tests for the configurable EVM shared-memory pre-allocation +//! ([`SharedMemoryCapacity`]) wired through [`EvmCacheBuilder`]. +//! +//! Covers the three user-facing behaviors: the default, an explicit `Fixed` size, +//! and `Auto` sizing from the chain state loaded at build time (the +//! "intelligently allocate from a bincode state file" path). All offline. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy_primitives::{Address, U256}; +use alloy_provider::RootProvider; +use alloy_provider::network::AnyNetwork; +use alloy_rpc_client::RpcClient; +use alloy_transport::mock::Asserter; +use anyhow::Result; +use evm_fork_cache::cache::{CacheConfig, EvmCacheBuilder, SharedMemoryCapacity}; + +fn mock_provider() -> Arc> { + Arc::new(RootProvider::::new(RpcClient::mocked( + Asserter::new(), + ))) +} + +/// A unique temp dir for a disk-backed cache (no two tests collide). +fn unique_cache_dir(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("evm_fork_cache_smc_{tag}_{nanos}")) +} + +#[tokio::test(flavor = "multi_thread")] +async fn default_capacity_is_fixed_64k() -> Result<()> { + let cache = EvmCacheBuilder::new(mock_provider()).build().await; + assert_eq!( + cache.shared_memory_capacity(), + 64_000, + "the default must be Fixed(64_000)" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn fixed_capacity_is_honored() -> Result<()> { + let cache = EvmCacheBuilder::new(mock_provider()) + .shared_memory_capacity(SharedMemoryCapacity::Fixed(8_192)) + .build() + .await; + assert_eq!(cache.shared_memory_capacity(), 8_192); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn auto_capacity_with_no_loaded_state_falls_back_to_floor() -> Result<()> { + // No cache_config → nothing loaded → Auto resolves to the 64 KB floor. + let cache = EvmCacheBuilder::new(mock_provider()) + .shared_memory_capacity(SharedMemoryCapacity::Auto) + .build() + .await; + assert_eq!( + cache.shared_memory_capacity(), + SharedMemoryCapacity::MIN_AUTO + ); + Ok(()) +} + +/// The headline: `Auto` sizes the buffer from the chain state in a loaded bincode +/// state file. A first cache persists 10 000 storage slots; a second cache built +/// with `Auto` over the same `CacheConfig` loads them and pre-allocates +/// `10_000 * 16 = 160_000` bytes (vs. the 64 KB default). +#[tokio::test(flavor = "multi_thread")] +async fn auto_capacity_scales_with_loaded_binary_state() -> Result<()> { + let dir = unique_cache_dir("auto"); + let cfg = CacheConfig::new(&dir, 1, Default::default(), Default::default()); + + // First cache: seed 10k slots into layer 2 and persist to the bincode state file. + { + let mut cache = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg.clone()) + .build() + .await; + let token = Address::repeat_byte(0x11); + let batch: Vec<(Address, U256, U256)> = (0..10_000u64) + .map(|i| (token, U256::from(i), U256::from(i + 1))) + .collect(); + cache.inject_storage_batch(&batch); + cache.flush(); // writes evm_state.bin + } + + // Second cache: Auto over the same config loads the 10k slots and sizes from them. + let reloaded = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg.clone()) + .shared_memory_capacity(SharedMemoryCapacity::Auto) + .build() + .await; + assert_eq!( + reloaded.shared_memory_capacity(), + 160_000, + "Auto must size from the 10k loaded slots (10_000 * 16 bytes)" + ); + + // A Fixed override ignores the loaded state. + let fixed = EvmCacheBuilder::new(mock_provider()) + .cache_config(cfg.clone()) + .shared_memory_capacity(SharedMemoryCapacity::Fixed(64_000)) + .build() + .await; + assert_eq!(fixed.shared_memory_capacity(), 64_000); + + let _ = std::fs::remove_dir_all(&dir); + Ok(()) +}