diff --git a/Cargo.lock b/Cargo.lock index a4bf7f2..2c4fad3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.19" @@ -1791,6 +1797,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -1853,6 +1865,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2131,6 +2170,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -2992,7 +3067,9 @@ dependencies = [ "alloy-evm", "alloy-primitives", "alloy-sol-types", + "criterion", "ev-precompiles", + "rand 0.8.5", "reth-evm", "reth-evm-ethereum", "reth-primitives", @@ -3545,6 +3622,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash-db" version = "0.15.2" @@ -4236,6 +4324,17 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -5250,6 +5349,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "op-alloy-consensus" version = "0.22.1" @@ -5664,6 +5769,34 @@ dependencies = [ "crunchy", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "polyval" version = "0.6.2" @@ -10342,6 +10475,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.9.0" diff --git a/Cargo.toml b/Cargo.toml index d226fcf..cf6b6b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ rand = "0.8" tempfile = "3.10" hex = "0.4" url = "2.5" +criterion = { version = "0.5", features = ["html_reports"] } [workspace.lints] rust.missing_debug_implementations = "warn" diff --git a/crates/ev-revm/Cargo.toml b/crates/ev-revm/Cargo.toml index 9ee6715..9718833 100644 --- a/crates/ev-revm/Cargo.toml +++ b/crates/ev-revm/Cargo.toml @@ -22,6 +22,12 @@ ev-precompiles = { path = "../ev-precompiles" } [dev-dependencies] alloy-sol-types.workspace = true +criterion.workspace = true +rand.workspace = true + +[[bench]] +name = "cache_benchmark" +harness = false [lints] workspace = true diff --git a/crates/ev-revm/benches/cache_benchmark.rs b/crates/ev-revm/benches/cache_benchmark.rs new file mode 100644 index 0000000..a8bb72d --- /dev/null +++ b/crates/ev-revm/benches/cache_benchmark.rs @@ -0,0 +1,253 @@ +//! Benchmarks for bytecode caching layer. +//! +//! This benchmark compares performance of direct database access vs bytecode caching. +//! Bytecode is immutable after deployment, making it ideal for caching. +//! +//! Run with: cargo bench -p ev-revm + +use alloy_primitives::{Address, B256, U256}; +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use ev_revm::cache::{BytecodeCache, CachedDatabase}; +use rand::{Rng, SeedableRng}; +use reth_revm::revm::{ + context_interface::Database, + state::{AccountInfo, Bytecode}, +}; +use std::{collections::HashMap, sync::Arc}; + +/// Mock database with configurable latency simulation. +#[derive(Debug)] +struct MockDatabase { + bytecodes: HashMap, + storage: HashMap<(Address, U256), U256>, + /// Simulated latency per operation (in nanoseconds worth of work) + latency_factor: usize, +} + +impl MockDatabase { + fn new(latency_factor: usize) -> Self { + Self { + bytecodes: HashMap::new(), + storage: HashMap::new(), + latency_factor, + } + } + + fn with_bytecodes(mut self, count: usize) -> Self { + for i in 0..count { + let code_hash = B256::repeat_byte((i % 256) as u8); + // Create realistic bytecode (average contract ~5KB) + let bytecode_size = 5000 + (i % 1000); + let mut code = vec![0x60u8; bytecode_size]; // PUSH1 opcodes + code[0] = 0x60; + code[1] = (i % 256) as u8; + self.bytecodes + .insert(code_hash, Bytecode::new_raw(code.into())); + } + self + } + + /// Simulate database latency by doing busy work + fn simulate_latency(&self) { + if self.latency_factor > 0 { + let mut sum = 0u64; + for i in 0..self.latency_factor { + sum = sum.wrapping_add(i as u64); + } + black_box(sum); + } + } +} + +impl Database for MockDatabase { + type Error = std::convert::Infallible; + + fn basic(&mut self, _address: Address) -> Result, Self::Error> { + self.simulate_latency(); + Ok(None) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.simulate_latency(); + Ok(self.bytecodes.get(&code_hash).cloned().unwrap_or_default()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + self.simulate_latency(); + Ok(self + .storage + .get(&(address, index)) + .copied() + .unwrap_or(U256::ZERO)) + } + + fn block_hash(&mut self, _number: u64) -> Result { + self.simulate_latency(); + Ok(B256::ZERO) + } +} + +/// Benchmark bytecode cache hits - all requests hit the cache +fn bench_bytecode_cache_hit(c: &mut Criterion) { + let mut group = c.benchmark_group("bytecode_cache_hit"); + + // Test different cache sizes + for &num_contracts in &[10, 100, 1000] { + group.throughput(Throughput::Elements(num_contracts as u64)); + + // Pre-populate cache + let cache = Arc::new(BytecodeCache::new(num_contracts * 2)); + for i in 0..num_contracts { + let code_hash = B256::repeat_byte((i % 256) as u8); + let bytecode = Bytecode::new_raw(vec![0x60, (i % 256) as u8].into()); + cache.insert(code_hash, bytecode); + } + + group.bench_with_input( + BenchmarkId::new("cache_hit", num_contracts), + &num_contracts, + |b, &n| { + b.iter(|| { + for i in 0..n { + let code_hash = B256::repeat_byte((i % 256) as u8); + let result = cache.get(&code_hash); + black_box(result); + } + }) + }, + ); + } + + group.finish(); +} + +/// Benchmark cache miss vs hit - demonstrates the benefit of caching +fn bench_bytecode_cache_miss_vs_hit(c: &mut Criterion) { + let mut group = c.benchmark_group("bytecode_cache_miss_vs_hit"); + + let num_contracts = 100; + let latency_factor = 1000; // Simulate some database latency + + group.throughput(Throughput::Elements(num_contracts as u64)); + + // Without cache (all misses go to database) + group.bench_function("no_cache", |b| { + let mut db = MockDatabase::new(latency_factor).with_bytecodes(num_contracts); + b.iter(|| { + for i in 0..num_contracts { + let code_hash = B256::repeat_byte((i % 256) as u8); + let _result = db.code_by_hash(code_hash); + } + }) + }); + + // With cache (first pass misses, subsequent passes hit) + group.bench_function("with_cache_warm", |b| { + let db = MockDatabase::new(latency_factor).with_bytecodes(num_contracts); + let cache = Arc::new(BytecodeCache::new(num_contracts * 2)); + let mut cached_db = CachedDatabase::new(db, cache); + + // Warm up the cache + for i in 0..num_contracts { + let code_hash = B256::repeat_byte((i % 256) as u8); + let _ = cached_db.code_by_hash(code_hash); + } + + b.iter(|| { + for i in 0..num_contracts { + let code_hash = B256::repeat_byte((i % 256) as u8); + let _result = cached_db.code_by_hash(code_hash); + } + }) + }); + + group.finish(); +} + +/// Benchmark LRU eviction behavior +fn bench_bytecode_cache_eviction(c: &mut Criterion) { + let mut group = c.benchmark_group("bytecode_cache_eviction"); + + let cache_size = 100; + let num_contracts = 200; // More contracts than cache can hold + + group.throughput(Throughput::Elements(num_contracts as u64)); + + // Random access pattern (will cause evictions) + group.bench_function("random_access", |b| { + let db = MockDatabase::new(100).with_bytecodes(num_contracts); + let cache = Arc::new(BytecodeCache::new(cache_size)); + let mut cached_db = CachedDatabase::new(db, cache); + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + b.iter(|| { + for _ in 0..num_contracts { + let i = rng.gen_range(0..num_contracts); + let code_hash = B256::repeat_byte((i % 256) as u8); + let _result = cached_db.code_by_hash(code_hash); + } + }) + }); + + // Sequential access pattern (better cache locality) + group.bench_function("sequential_access", |b| { + let db = MockDatabase::new(100).with_bytecodes(num_contracts); + let cache = Arc::new(BytecodeCache::new(cache_size)); + let mut cached_db = CachedDatabase::new(db, cache); + + b.iter(|| { + for i in 0..num_contracts { + let code_hash = B256::repeat_byte((i % 256) as u8); + let _result = cached_db.code_by_hash(code_hash); + } + }) + }); + + group.finish(); +} + +/// Benchmark realistic workload with mixed operations +fn bench_realistic_workload(c: &mut Criterion) { + let mut group = c.benchmark_group("realistic_workload"); + + let num_contracts = 50; + let ops_per_iteration = 1000; + let latency_factor = 500; + + group.throughput(Throughput::Elements(ops_per_iteration as u64)); + + // Simulate a realistic workload where some contracts are called frequently + // (hot contracts) and others are called rarely (cold contracts) + group.bench_function("hot_cold_distribution", |b| { + let db = MockDatabase::new(latency_factor).with_bytecodes(num_contracts); + let cache = Arc::new(BytecodeCache::new(num_contracts)); + let mut cached_db = CachedDatabase::new(db, cache); + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + // 80% of calls go to 20% of contracts (hot contracts: 0-9) + // 20% of calls go to 80% of contracts (cold contracts: 10-49) + b.iter(|| { + for _ in 0..ops_per_iteration { + let i = if rng.gen_bool(0.8) { + rng.gen_range(0..10) // Hot contract + } else { + rng.gen_range(10..num_contracts) // Cold contract + }; + let code_hash = B256::repeat_byte((i % 256) as u8); + let _result = cached_db.code_by_hash(code_hash); + } + }) + }); + + group.finish(); +} + +#[allow(missing_docs)] +criterion_group!( + benches, + bench_bytecode_cache_hit, + bench_bytecode_cache_miss_vs_hit, + bench_bytecode_cache_eviction, + bench_realistic_workload, +); +criterion_main!(benches); diff --git a/crates/ev-revm/src/cache.rs b/crates/ev-revm/src/cache.rs new file mode 100644 index 0000000..1a1485b --- /dev/null +++ b/crates/ev-revm/src/cache.rs @@ -0,0 +1,423 @@ +//! Caching layer for EVM database operations. +//! +//! This module provides a bytecode cache wrapper for database operations. +//! Contract bytecode is immutable after deployment, making it ideal for caching. + +use alloy_primitives::{Address, B256, U256}; +use reth_revm::revm::{ + context_interface::Database, + state::{AccountInfo, Bytecode}, +}; +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, +}; + +/// Thread-safe bytecode cache using LRU eviction strategy. +/// +/// Contract bytecode is immutable after deployment, making it an ideal +/// candidate for caching. This cache stores bytecode by its code hash, +/// avoiding repeated database lookups for frequently-called contracts. +#[derive(Debug)] +pub struct BytecodeCache { + /// The actual cache storage, protected by a `RwLock` for thread-safety. + /// Values are Arc'd to allow cheap cloning when returning cached bytecode. + cache: RwLock, + /// Maximum number of entries before eviction + max_entries: usize, +} + +/// Simple LRU cache implementation +#[derive(Debug)] +struct LruCache { + /// Map from code hash to (bytecode, `access_order`) + entries: HashMap, u64)>, + /// Counter for tracking access order + access_counter: u64, +} + +impl LruCache { + fn new() -> Self { + Self { + entries: HashMap::new(), + access_counter: 0, + } + } + + fn get(&mut self, key: &B256) -> Option> { + if let Some((bytecode, order)) = self.entries.get_mut(key) { + self.access_counter += 1; + *order = self.access_counter; + Some(Arc::clone(bytecode)) + } else { + None + } + } + + fn insert(&mut self, key: B256, value: Bytecode, max_entries: usize) { + // Evict oldest entries if at capacity + if self.entries.len() >= max_entries { + self.evict_oldest(max_entries / 2); + } + + self.access_counter += 1; + self.entries + .insert(key, (Arc::new(value), self.access_counter)); + } + + fn evict_oldest(&mut self, count: usize) { + if count == 0 || self.entries.is_empty() { + return; + } + + // Collect entries sorted by access order (oldest first) + let mut entries: Vec<_> = self.entries.iter().map(|(k, (_, o))| (*k, *o)).collect(); + entries.sort_by_key(|(_, order)| *order); + + // Remove the oldest entries + for (key, _) in entries.into_iter().take(count) { + self.entries.remove(&key); + } + } + + fn len(&self) -> usize { + self.entries.len() + } +} + +impl BytecodeCache { + /// Creates a new bytecode cache with the specified maximum number of entries. + /// + /// # Arguments + /// * `max_entries` - Maximum number of bytecode entries to cache before eviction + /// + /// # Panics + /// Panics if `max_entries` is 0. + pub fn new(max_entries: usize) -> Self { + assert!(max_entries > 0, "max_entries must be greater than 0"); + Self { + cache: RwLock::new(LruCache::new()), + max_entries, + } + } + + /// Creates a new bytecode cache with default capacity (10,000 entries). + /// + /// This is suitable for most use cases, providing cache for approximately + /// 10,000 unique contracts. + pub fn with_default_capacity() -> Self { + Self::new(10_000) + } + + /// Retrieves bytecode from the cache if present. + /// + /// Returns `None` if the bytecode is not cached. + pub fn get(&self, code_hash: &B256) -> Option { + let mut cache = self.cache.write().expect("cache lock poisoned"); + cache.get(code_hash).map(|arc| (*arc).clone()) + } + + /// Inserts bytecode into the cache. + /// + /// If the cache is at capacity, older entries will be evicted using LRU policy. + pub fn insert(&self, code_hash: B256, bytecode: Bytecode) { + // Don't cache empty bytecode + if bytecode.is_empty() { + return; + } + + let mut cache = self.cache.write().expect("cache lock poisoned"); + cache.insert(code_hash, bytecode, self.max_entries); + } + + /// Returns the current number of cached entries. + pub fn len(&self) -> usize { + self.cache.read().expect("cache lock poisoned").len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Clears all entries from the cache. + pub fn clear(&self) { + let mut cache = self.cache.write().expect("cache lock poisoned"); + cache.entries.clear(); + cache.access_counter = 0; + } +} + +impl Default for BytecodeCache { + fn default() -> Self { + Self::with_default_capacity() + } +} + +// ============================================================================ +// Cached Database +// ============================================================================ + +/// A database wrapper that adds bytecode caching to any underlying database. +/// +/// Contract bytecode is immutable after deployment, so caching provides +/// significant performance benefits for frequently-called contracts. +/// +/// # Example +/// +/// ```ignore +/// use ev_revm::cache::{BytecodeCache, CachedDatabase}; +/// use std::sync::Arc; +/// +/// let inner_db = StateProviderDatabase::new(&state_provider); +/// let bytecode_cache = Arc::new(BytecodeCache::with_default_capacity()); +/// let cached_db = CachedDatabase::new(inner_db, bytecode_cache); +/// ``` +#[derive(Debug)] +pub struct CachedDatabase { + /// The underlying database + inner: DB, + /// Shared bytecode cache + bytecode_cache: Arc, +} + +impl CachedDatabase { + /// Creates a new cached database wrapper. + /// + /// # Arguments + /// * `inner` - The underlying database to wrap + /// * `bytecode_cache` - Shared bytecode cache (can be shared across multiple databases) + pub const fn new(inner: DB, bytecode_cache: Arc) -> Self { + Self { + inner, + bytecode_cache, + } + } + + /// Returns a reference to the underlying database. + pub const fn inner(&self) -> &DB { + &self.inner + } + + /// Returns a mutable reference to the underlying database. + pub fn inner_mut(&mut self) -> &mut DB { + &mut self.inner + } + + /// Consumes the wrapper and returns the underlying database. + pub fn into_inner(self) -> DB { + self.inner + } + + /// Returns a reference to the bytecode cache. + pub const fn bytecode_cache(&self) -> &Arc { + &self.bytecode_cache + } + + /// Returns a reference to the bytecode cache (alias for backwards compatibility). + pub const fn cache(&self) -> &Arc { + &self.bytecode_cache + } +} + +impl Database for CachedDatabase { + type Error = DB::Error; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.inner.basic(address) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + // Check bytecode cache first + if let Some(cached) = self.bytecode_cache.get(&code_hash) { + return Ok(cached); + } + + // Cache miss - fetch from underlying database + let bytecode = self.inner.code_by_hash(code_hash)?; + + // Cache for future use + self.bytecode_cache.insert(code_hash, bytecode.clone()); + + Ok(bytecode) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + self.inner.storage(address, index) + } + + fn block_hash(&mut self, number: u64) -> Result { + self.inner.block_hash(number) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::bytes; + + #[test] + fn test_bytecode_cache_basic_operations() { + let cache = BytecodeCache::new(100); + + // Create a test bytecode + let code_hash = B256::repeat_byte(0x42); + let bytecode = Bytecode::new_raw(bytes!("6080604052")); + + // Initially not in cache + assert!(cache.get(&code_hash).is_none()); + + // Insert into cache + cache.insert(code_hash, bytecode.clone()); + + // Now should be retrievable + let cached = cache.get(&code_hash).expect("should be cached"); + assert_eq!(cached.bytes(), bytecode.bytes()); + } + + #[test] + fn test_bytecode_cache_empty_bytecode_not_cached() { + let cache = BytecodeCache::new(100); + let code_hash = B256::repeat_byte(0x42); + let empty_bytecode = Bytecode::new(); + + cache.insert(code_hash, empty_bytecode); + + // Empty bytecode should not be cached + assert!(cache.get(&code_hash).is_none()); + } + + #[test] + fn test_bytecode_cache_lru_eviction() { + let cache = BytecodeCache::new(3); + + // Insert 3 entries + for i in 0..3u8 { + let code_hash = B256::repeat_byte(i); + let bytecode = Bytecode::new_raw(vec![0x60, i].into()); + cache.insert(code_hash, bytecode); + } + + assert_eq!(cache.len(), 3); + + // Access entry 0 to make it recently used + cache.get(&B256::repeat_byte(0)); + + // Insert a 4th entry, should evict entry 1 (least recently used) + let code_hash_3 = B256::repeat_byte(3); + cache.insert(code_hash_3, Bytecode::new_raw(vec![0x60, 3].into())); + + // Entry 0 should still be present (was accessed) + assert!(cache.get(&B256::repeat_byte(0)).is_some()); + // Entry 3 should be present (just added) + assert!(cache.get(&B256::repeat_byte(3)).is_some()); + } + + #[test] + fn test_bytecode_cache_clear() { + let cache = BytecodeCache::new(100); + + // Insert some entries + for i in 0..5u8 { + let code_hash = B256::repeat_byte(i); + let bytecode = Bytecode::new_raw(vec![0x60, i].into()); + cache.insert(code_hash, bytecode); + } + + assert_eq!(cache.len(), 5); + + cache.clear(); + + assert!(cache.is_empty()); + } + + #[test] + #[should_panic(expected = "max_entries must be greater than 0")] + fn test_bytecode_cache_zero_capacity_panics() { + BytecodeCache::new(0); + } + + // Mock database for testing CachedDatabase + #[derive(Debug, Default)] + struct MockDatabase { + bytecodes: HashMap, + storage: HashMap<(Address, U256), U256>, + code_by_hash_call_count: std::cell::Cell, + } + + impl MockDatabase { + fn new() -> Self { + Self::default() + } + + fn with_bytecode(mut self, code_hash: B256, bytecode: Bytecode) -> Self { + self.bytecodes.insert(code_hash, bytecode); + self + } + + fn code_by_hash_call_count(&self) -> usize { + self.code_by_hash_call_count.get() + } + } + + impl Database for MockDatabase { + type Error = std::convert::Infallible; + + fn basic(&mut self, _address: Address) -> Result, Self::Error> { + Ok(None) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.code_by_hash_call_count + .set(self.code_by_hash_call_count.get() + 1); + Ok(self.bytecodes.get(&code_hash).cloned().unwrap_or_default()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + Ok(self + .storage + .get(&(address, index)) + .copied() + .unwrap_or(U256::ZERO)) + } + + fn block_hash(&mut self, _number: u64) -> Result { + Ok(B256::ZERO) + } + } + + #[test] + fn test_cached_database_cache_hit() { + let code_hash = B256::repeat_byte(0x42); + let bytecode = Bytecode::new_raw(bytes!("6080604052")); + + let mock_db = MockDatabase::new().with_bytecode(code_hash, bytecode.clone()); + let cache = Arc::new(BytecodeCache::new(100)); + let mut cached_db = CachedDatabase::new(mock_db, cache); + + // First call - cache miss, should hit database + let result1 = cached_db.code_by_hash(code_hash).unwrap(); + assert_eq!(result1.bytes(), bytecode.bytes()); + assert_eq!(cached_db.inner().code_by_hash_call_count(), 1); + + // Second call - cache hit, should NOT hit database + let result2 = cached_db.code_by_hash(code_hash).unwrap(); + assert_eq!(result2.bytes(), bytecode.bytes()); + assert_eq!(cached_db.inner().code_by_hash_call_count(), 1); // Still 1! + } + + #[test] + fn test_cached_database_delegates_other_methods() { + let mock_db = MockDatabase::new(); + let cache = Arc::new(BytecodeCache::new(100)); + let mut cached_db = CachedDatabase::new(mock_db, cache); + + // These should delegate to inner database + assert!(cached_db.basic(Address::ZERO).unwrap().is_none()); + assert_eq!( + cached_db.storage(Address::ZERO, U256::ZERO).unwrap(), + U256::ZERO + ); + assert_eq!(cached_db.block_hash(0).unwrap(), B256::ZERO); + } +} diff --git a/crates/ev-revm/src/lib.rs b/crates/ev-revm/src/lib.rs index da8401f..aff658e 100644 --- a/crates/ev-revm/src/lib.rs +++ b/crates/ev-revm/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod base_fee; +pub mod cache; pub mod config; pub mod evm; pub mod factory; @@ -9,6 +10,7 @@ pub mod handler; pub use api::EvBuilder; pub use base_fee::{BaseFeeRedirect, BaseFeeRedirectError}; +pub use cache::{BytecodeCache, CachedDatabase}; pub use config::{BaseFeeConfig, ConfigError}; pub use evm::{DefaultEvEvm, EvEvm}; pub use factory::{ diff --git a/crates/node/src/builder.rs b/crates/node/src/builder.rs index 23acc2c..b74fb3c 100644 --- a/crates/node/src/builder.rs +++ b/crates/node/src/builder.rs @@ -2,7 +2,7 @@ use crate::config::EvolvePayloadBuilderConfig; use alloy_consensus::transaction::Transaction; use alloy_evm::eth::EthEvmFactory; use alloy_primitives::Address; -use ev_revm::EvEvmFactory; +use ev_revm::{BytecodeCache, CachedDatabase, EvEvmFactory}; use evolve_ev_reth::EvolvePayloadAttributes; use reth_chainspec::{ChainSpec, ChainSpecProvider}; use reth_errors::RethError; @@ -29,6 +29,8 @@ pub struct EvolvePayloadBuilder { pub evm_config: EvolveEthEvmConfig, /// Parsed Evolve-specific configuration pub config: EvolvePayloadBuilderConfig, + /// Shared bytecode cache for caching contract bytecode across payloads + bytecode_cache: Arc, } impl EvolvePayloadBuilder @@ -40,11 +42,29 @@ where + Sync + 'static, { + /// Default bytecode cache capacity (number of unique contracts to cache) + const DEFAULT_BYTECODE_CACHE_CAPACITY: usize = 10_000; + /// Creates a new instance of `EvolvePayloadBuilder` pub fn new( client: Arc, evm_config: EvolveEthEvmConfig, config: EvolvePayloadBuilderConfig, + ) -> Self { + Self::with_cache_capacity( + client, + evm_config, + config, + Self::DEFAULT_BYTECODE_CACHE_CAPACITY, + ) + } + + /// Creates a new instance of `EvolvePayloadBuilder` with custom bytecode cache capacity + pub fn with_cache_capacity( + client: Arc, + evm_config: EvolveEthEvmConfig, + config: EvolvePayloadBuilderConfig, + bytecode_cache_capacity: usize, ) -> Self { if let Some((sink, activation)) = config.base_fee_redirect_settings() { info!( @@ -55,10 +75,17 @@ where ); } + info!( + target: "ev-reth", + cache_capacity = bytecode_cache_capacity, + "Bytecode cache initialized" + ); + Self { client, evm_config, config, + bytecode_cache: Arc::new(BytecodeCache::new(bytecode_cache_capacity)), } } @@ -75,10 +102,11 @@ where // Get the latest state provider let state_provider = self.client.latest().map_err(PayloadBuilderError::other)?; - // Create a database from the state provider - let db = StateProviderDatabase::new(&state_provider); + // Create a database from the state provider with bytecode caching + let inner_db = StateProviderDatabase::new(&state_provider); + let cached_db = CachedDatabase::new(inner_db, Arc::clone(&self.bytecode_cache)); let mut state_db = State::builder() - .with_database(db) + .with_database(cached_db) .with_bundle_update() .build();