Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,80 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)).
- **`protocols` feature** (default-on) gating the Uniswap V2/V3 storage layouts,
V3 tick snapshots, and `inject_v3_*` / `inject_v2_pool_metadata` helpers, so
the generic engine builds with `--no-default-features`.
- **Copy-on-write snapshots** (Phase 5, Pillar A) — `create_snapshot` is now a
two-tier copy-on-write view instead of an O(total state) deep clone. The cold
`BlockchainDb` index (layer 2) is flattened once into an internal, immutable,
`Arc`-shared base (`Arc` per account storage map, structural sharing — no new
dependency, Decision D1), memoized across snapshots and rebuilt copy-on-write
only for the addresses that changed; each `create_snapshot` then folds just the
hot CacheDB delta (layer 1) over a cheap `Arc::clone` of that base. Reads stay
O(1) and lock-free and are bit-for-bit identical to the deep clone (pinned by
the `tests/cow_snapshot.rs` differential-equivalence gate). The retained
`EvmCache::create_snapshot_deep_clone()` (`#[doc(hidden)] pub`, Decision D3) is
the equivalence reference and the A/B benchmark baseline. `EvmSnapshot` stays
`Send + Sync` and `EvmOverlay` stays `Send`.
- **`EvmOverlay::reset()`** (Phase 5, Pillar A.2) — recycle one overlay across
many simulations against the same snapshot without reallocating: it clears the
per-simulation dirty layer (keeping the snapshot `Arc`, `ext_db`, and the
reusable shared-memory buffer), reading the pristine snapshot again and behaving
exactly like a freshly-built overlay. The 64 KiB shared-memory buffer is also
recycled across the build→transact→revert call methods (stored as a plain
`Vec<u8>`, so the overlay stays `Send`).
- **Configurable EVM shared-memory pre-allocation** — `SharedMemoryCapacity`
(`Fixed(usize)` / `Auto`, default `Fixed(64 * 1024)` / 65,536 bytes) 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 KiB 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.
- **Explicit cold-account materialization** — `StateUpdate::AccountUpsert` and
`StateUpdate::account_upsert(...)` intentionally materialize an account absent
from both layers. Normal `StateUpdate::Account` patches are now cold-aware and
surface skipped cold patches through `StateDiff.skipped_accounts:
Vec<SkippedAccountPatch>`.
- **Invalidating layer-2 mutation wrapper** — `EvmCache::with_blockchain_db_mut`
runs a synchronous direct `BlockchainDb` mutation and invalidates the Phase 5
memoized COW base automatically after the closure returns.
- **Exact access-list RLP data-gas helper** —
`access_list::access_list_rlp_data_gas(&AccessList)` returns the EIP-2930 RLP
calldata gas for an access list and backs the L2 profitability calculation.
- **Versioned on-disk cache envelope** — binary EVM state, bytecode,
`ImmutableDataCache`, and V3 tick snapshot cache files now start with
crate-specific magic bytes plus a `u32` version before the bincode payload.

### Changed

- **`EvmCache::create_snapshot` is now `&mut self`** (Phase 5, Decision D5) —
taking a snapshot memoizes/refreshes the cold copy-on-write base, which requires
a mutable borrow. All callers (the freshness controller, tests, examples,
benches) are updated; the return type (`Arc<EvmSnapshot>`) is unchanged.
Permitted under the pre-1.0 break policy.
- **`EvmCache::inject_storage_batch` is now `&mut self`** (Phase 5) — the
layer-2 bulk write now marks the touched addresses dirty for the memoized
copy-on-write base. The write itself is still a direct backend (layer-2) write
with the same semantics; only the receiver mutability changed.
- **Raw layer-2 handles were renamed to unchecked accessors** (Phase 5) —
`EvmCache::blockchain_db()` is now `unchecked_blockchain_db()` and
`EvmCache::backend()` is now `unchecked_backend()`. The rename makes the
bypass explicit; use `with_blockchain_db_mut` for synchronous direct writes that
should automatically invalidate the snapshot base.
- **Persistence APIs now return `Result<()>`** — `cache::save_binary_state`,
`PrefetchRegistry::save`, and `EvmCache::flush` report serialization,
directory-creation, and write failures to explicit callers. `Drop` remains
best-effort and logs `flush()` errors.
- **Block re-pins clear stale context** — `set_block` sets `block_number` only
for concrete numeric pins, clears it for tag/hash/`None` pins, and clears stale
`basefee` on block changes and on non-concrete pin calls that can drift under
the same tag. `repin_to_block` follows the same no-stale-basefee rule; callers
refresh `NUMBER`/`BASEFEE` via `set_block_context` after fetching the new
header.
- **Legacy raw-bincode cache files are treated as misses** — the versioned cache
envelope intentionally rejects unversioned `evm_state.bin`, `bytecodes.bin`,
`immutable_data.bin`, and `v3_tick_snapshots.bin` payloads rather than trying
to deserialize ambiguous layouts.
- Simulation entry points that distinguish failure modes return
`SimulationResult<T>` (`Result<T, SimError>`), separating decoded reverts,
EVM halts, and host errors. `SimulationErrorKind` remains as a deprecated alias.
Expand All @@ -160,6 +231,19 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)).

### Fixed

- **Cold absolute account patches no longer mask on-chain accounts.**
`StateUpdate::Account` on an account absent from both layers now skips instead
of writing `AccountInfo::default()` fields through the shared backend. The
skipped patch is visible in `StateDiff.skipped_accounts`; intentional cold
creation uses `StateUpdate::AccountUpsert`.
- **Access-list profitability no longer conflates provider failures with
unprofitable lists.** `SmartAccessList::into_access_list_if_profitable` and
`access_list_if_profitable` now propagate provider/pricing failures as `Err`
and reserve `Ok(None)` for empty, zero-priced, or genuinely unprofitable lists.
- **`simulate_call_with_balance_deltas` now reports a real access list.** It
extracts the EIP-2930 touched account/slot list from the EVM journal before
commit/revert, including the pre/post `balanceOf` reads and the simulated call,
instead of returning `AccessList::default()`.
- **`cached_storage_value` silent-corruption bug** (Phase 3 §16.0, audit HIGH +
MED). For a storage slot absent from an overlay account whose revm
`account_state` is `StorageCleared` or `NotExisting`, the accessor now returns
Expand All @@ -175,7 +259,9 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)).
**skips both layer writes** (returning an empty diff) when no field actually
changes, instead of unconditionally inserting `AccountInfo::default()` into the
shared backend for an all-`None` (or value-unchanged) patch on an absent address.
A real field change still materializes the backend account (unchanged intent).
Phase 5 later tightened this further: real field changes on cold accounts now
skip through `StateDiff.skipped_accounts` unless the caller uses
`StateUpdate::AccountUpsert`.
- **`account_state`-awareness extended to the snapshot + account-info paths**
(Phase 3 fix-review, HIGH + MED). A follow-up adversarial review found the §16.0
`cached_storage_value` fix had not been propagated to two sibling read paths:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ alloy-eips = "1.0.38"
alloy-network = "1.0.38"
alloy-primitives = { version = "1.4", features = ["map"] }
alloy-provider = "1.0.38"
alloy-rlp = "0.3"
alloy-rpc-client = "1.0.38"
alloy-rpc-types-eth = "1.0.38"
alloy-sol-types = "1.4"
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,10 @@ println!("installed {} bytes at {}", etched.code_size, etched.target_address);

## Benchmarks

Criterion benchmarks live in [`benches/`](benches). The offline benches are the
baseline against which the planned copy-on-write snapshot rewrite (roadmap
Pillar A) will be measured, so they exercise the real hot paths at a range of
cache sizes:
Criterion benchmarks live in [`benches/`](benches). The offline benches exercise
the current hot paths at a range of cache sizes, including the Phase 5
copy-on-write snapshot implementation and retained deep-clone baselines where
useful for A/B comparison:

| Bench | Measures |
| --- | --- |
Expand Down
146 changes: 104 additions & 42 deletions benches/simulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@
//! bundle simulation, and batched storage injection.
//!
//! These run fully offline (mocked provider) so they're reproducible. They
//! establish the baseline for the Pillar A (copy-on-write snapshot) rewrite:
//! `create_snapshot` is currently an O(total state) deep clone, so its cost
//! scales with the populated cache size (the `create_snapshot` group sweeps
//! 100 → 10,000 accounts to show that slope). Once Pillar A lands, the same
//! sweep should flatten toward O(changed state) — re-run this group before and
//! after to quantify the win. The `overlay_fanout` group measures the other
//! half of the value proposition: how cheaply one frozen snapshot fans out into
//! many isolated simulations.
//! quantify the Pillar A (copy-on-write snapshot) win.
//!
//! Expected shapes:
//! - **`create_snapshot` group (A/B).** The cold index is seeded into **layer 2**
//! via `inject_storage_batch` (`populated_cache_layer2`), the way a fork cache
//! actually holds it. For each size it benches both the COW `create_snapshot`
//! and the retained `create_snapshot_deep_clone`. The deep clone is an O(total
//! state) copy, so its cost slopes up with the index size; the COW path shares
//! the memoized base and avoids cloning total storage slots. It still scans
//! accounts and new layer-1 entries, so it should be much flatter than the deep
//! clone, especially as slots/account grows, but not strictly flat by account
//! count.
//! - **`resnapshot_hot_loop`.** Warms the base with one snapshot, applies a small
//! `apply_updates` layer-1 mutation, then measures `create_snapshot`. This is
//! the memoization win: the COW path avoids cloning cold storage slots but
//! remains sensitive to account scans and new layer-1 entries.
//! - **`overlay_fanout`.** Measures fanning one frozen snapshot out into many
//! isolated simulations, comparing a fresh `EvmOverlay::new` per sim against a
//! single `reset()`-recycled overlay (Pillar A.2).

use std::hint::black_box;
use std::sync::Arc;
Expand Down Expand Up @@ -49,47 +60,86 @@ fn offline_cache(rt: &Runtime) -> EvmCache {
rt.block_on(EvmCache::new(Arc::new(provider), None))
}

/// A cache populated with `accounts` accounts, each holding `slots_per` slots.
fn populated_cache(rt: &Runtime, accounts: usize, slots_per: usize) -> EvmCache {
/// A cache whose cold index lives in **layer 2** — seeded via
/// `inject_storage_batch`, the path a fork cache actually uses to bulk-load its
/// cold state. This is what the COW `create_snapshot` memoizes into its base.
fn populated_cache_layer2(rt: &Runtime, accounts: usize, slots_per: usize) -> EvmCache {
let mut cache = offline_cache(rt);
let mut batch: Vec<(Address, U256, U256)> = Vec::with_capacity(accounts * slots_per);
for a in 0..accounts {
let address = addr(a);
cache
.db_mut()
.insert_account_info(address, AccountInfo::default());
for s in 0..slots_per {
cache
.db_mut()
.insert_account_storage(
address,
U256::from(s as u64),
U256::from((a * 31 + s) as u64),
)
.unwrap();
batch.push((
address,
U256::from(s as u64),
U256::from((a * 31 + s) as u64),
));
}
}
cache.inject_storage_batch(&batch);
cache
}

/// A/B snapshot creation across cold-index sizes: the COW `create_snapshot` vs.
/// the retained `create_snapshot_deep_clone`, both over a layer-2-seeded index.
///
/// The deep clone slopes up with total slots; the COW path, after a warm-up
/// snapshot has memoized the base, avoids cloning those slots but still pays the
/// O(accounts) growth scan.
fn bench_create_snapshot(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let mut group = c.benchmark_group("create_snapshot");
// Sweep from a small pool up to a production-scale index (10k contracts) so
// the O(total state) slope of the current deep clone is visible. Pillar A
// (copy-on-write) should flatten this curve.
for &(accounts, slots) in &[
(100usize, 8usize),
(1_000, 8),
(2_000, 16),
(5_000, 16),
(10_000, 16),
] {
let cache = populated_cache(&rt, accounts, slots);
let mut cache = populated_cache_layer2(&rt, accounts, slots);
// Warm the memoized base once so the COW measurement reflects the
// steady-state (reuse) cost, not the first full build.
black_box(cache.create_snapshot());
group.throughput(criterion::Throughput::Elements((accounts * slots) as u64));
group.bench_with_input(
BenchmarkId::new("cow", format!("{accounts}acct_x{slots}slot")),
&accounts,
|b, _| b.iter(|| black_box(cache.create_snapshot())),
);
group.bench_with_input(
BenchmarkId::new("deep_clone", format!("{accounts}acct_x{slots}slot")),
&accounts,
|b, _| b.iter(|| black_box(cache.create_snapshot_deep_clone())),
);
}
group.finish();
}

/// The memoization win: a hot re-snapshot loop. Warm the base once, apply a
/// *small* layer-1 mutation, then measure `create_snapshot`. Cost should track
/// account scanning plus new layer-1 entries, staying much flatter than the deep
/// clone as cold storage grows.
fn bench_resnapshot_hot_loop(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let mut group = c.benchmark_group("resnapshot_hot_loop");
for &(accounts, slots) in &[(1_000usize, 8usize), (5_000, 16), (10_000, 16)] {
let mut cache = populated_cache_layer2(&rt, accounts, slots);
// Warm the base.
black_box(cache.create_snapshot());
// A handful of layer-1 writes (does not dirty the memoized base).
let target = addr(0);
group.throughput(criterion::Throughput::Elements((accounts * slots) as u64));
group.bench_with_input(
BenchmarkId::from_parameter(format!("{accounts}acct_x{slots}slot")),
&cache,
|b, cache| b.iter(|| black_box(cache.create_snapshot())),
&accounts,
|b, _| {
b.iter(|| {
cache
.db_mut()
.insert_account_info(target, AccountInfo::default());
black_box(cache.create_snapshot());
})
},
);
}
group.finish();
Expand Down Expand Up @@ -125,20 +175,31 @@ fn bench_overlay_fanout(c: &mut Criterion) {

let mut group = c.benchmark_group("overlay_fanout");
for &k in &[1usize, 8, 32] {
group.bench_with_input(
BenchmarkId::from_parameter(format!("{k}way")),
&k,
|b, &k| {
b.iter(|| {
for _ in 0..k {
let mut overlay = EvmOverlay::new(snapshot.clone(), None);
let result = overlay.call_raw(owner, token, calldata.clone()).unwrap();
debug_assert!(matches!(result, ExecutionResult::Success { .. }));
black_box(result);
}
})
},
);
// Baseline: a fresh `EvmOverlay::new` (+ dirty maps + Arc clone + buffer)
// per simulation.
group.bench_with_input(BenchmarkId::new("new_per_sim", k), &k, |b, &k| {
b.iter(|| {
for _ in 0..k {
let mut overlay = EvmOverlay::new(snapshot.clone(), None);
let result = overlay.call_raw(owner, token, calldata.clone()).unwrap();
debug_assert!(matches!(result, ExecutionResult::Success { .. }));
black_box(result);
}
})
});
// Pillar A.2: one overlay built once, `reset()` between sims (reuses the
// dirty maps, the snapshot Arc, and the shared-memory buffer).
group.bench_with_input(BenchmarkId::new("reset_recycled", k), &k, |b, &k| {
b.iter(|| {
let mut overlay = EvmOverlay::new(snapshot.clone(), None);
for _ in 0..k {
let result = overlay.call_raw(owner, token, calldata.clone()).unwrap();
debug_assert!(matches!(result, ExecutionResult::Success { .. }));
black_box(result);
overlay.reset();
}
})
});
}
group.finish();
}
Expand Down Expand Up @@ -253,7 +314,7 @@ fn bench_sim_bundle(c: &mut Criterion) {
/// Batched direct storage injection (the bypass-RPC write path) across sizes.
fn bench_inject_storage_batch(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let cache = offline_cache(&rt);
let mut cache = offline_cache(&rt);

let mut group = c.benchmark_group("inject_storage_batch");
for &n in &[100usize, 1_000, 10_000] {
Expand All @@ -271,6 +332,7 @@ fn bench_inject_storage_batch(c: &mut Criterion) {
criterion_group!(
benches,
bench_create_snapshot,
bench_resnapshot_hot_loop,
bench_overlay_fanout,
bench_cache_call_raw,
bench_sim_bundle,
Expand Down
8 changes: 4 additions & 4 deletions benches/state_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ fn bench_apply_per_variant(c: &mut Criterion) {
group.bench_function("purge_all_storage", |b| {
b.iter_batched(
|| {
let cache = pool_cache(&rt);
let mut cache = pool_cache(&rt);
cache.inject_storage_batch(&[
(POOL, U256::from(0), U256::from(1)),
(POOL, U256::from(1), U256::from(2)),
Expand All @@ -203,7 +203,7 @@ fn bench_apply_per_variant(c: &mut Criterion) {
group.bench_function("purge_account", |b| {
b.iter_batched(
|| {
let cache = pool_cache(&rt);
let mut cache = pool_cache(&rt);
cache.inject_storage_batch(&[
(POOL, U256::from(0), U256::from(1)),
(POOL, U256::from(1), U256::from(2)),
Expand All @@ -223,7 +223,7 @@ fn bench_apply_per_variant(c: &mut Criterion) {
group.bench_function("purge_slots", |b| {
b.iter_batched(
|| {
let cache = pool_cache(&rt);
let mut cache = pool_cache(&rt);
cache.inject_storage_batch(&[
(POOL, U256::from(0), U256::from(1)),
(POOL, U256::from(1), U256::from(2)),
Expand Down Expand Up @@ -254,7 +254,7 @@ fn bench_apply_heterogeneous(c: &mut Criterion) {
group.bench_function("slot_account_purge", |b| {
b.iter_batched(
|| {
let cache = pool_cache(&rt);
let mut cache = pool_cache(&rt);
cache.inject_storage_batch(&[(POOL, U256::from(9), U256::from(1))]);
cache
},
Expand Down
Loading
Loading