Skip to content

Phase 5: copy-on-write snapshots (Pillar A) + overlay reuse - #4

Merged
KaiCode2 merged 10 commits into
phase-3-state-updatesfrom
phase-5-cow-snapshots
Jun 17, 2026
Merged

Phase 5: copy-on-write snapshots (Pillar A) + overlay reuse#4
KaiCode2 merged 10 commits into
phase-3-state-updatesfrom
phase-5-cow-snapshots

Conversation

@KaiCode2

Copy link
Copy Markdown
Owner

Implements Pillar A of the roadmap: replace the O(total-state) deep-clone create_snapshot with a copy-on-write snapshot, and add overlay buffer/instance reuse. Stacked on phase-3-state-updates (which now carries the merged Phase 4 work, #3).

Design (locked decisions D1–D5, see docs/phase-5-spec.md)

A two-tier snapshot: the cold layer-2 BlockchainDb index is flattened once into an immutable Arc<BaseState> (per-account storage shared by Arc), memoized across snapshots and rebuilt copy-on-write only for changed addresses; each snapshot folds just the hot layer-1 CacheDB delta over a cheap Arc::clone.

  • D1Arc-shared maps, not a persistent/HAMT crate: reads stay O(1) and lock-free, no per-SLOAD regression, no new dependency.
  • D2 — base memoized immutable; every controlled layer-2 write marks it dirty (over-invalidation is safe, silent staleness is not). Uncontrolled append-only lazy-fetch growth is caught by an O(accounts) length-scan.
  • D3create_snapshot_deep_clone() retained as the A/B baseline and the differential read-equivalence reference.
  • D4EvmOverlay::reset() recycles an overlay across sims; the 64 KB shared-memory buffer is reused via a Send-preserving take/reclaim (plain Vec field + method-local Rc).
  • D5create_snapshot is now &mut self (it memoizes the base). All call sites updated.

EvmSnapshot stays Send + Sync, EvmOverlay stays Send; read semantics (StorageCleared/NotExisting/two-layer precedence) are bit-for-bit identical to the legacy flatten.

Benchmarks (benches/simulation.rs, measured)

create_snapshot COW vs the legacy deep clone, cold index in layer 2:

cold index COW deep clone speedup
100 × 8 1.8 µs 49.9 µs ~28×
1 000 × 8 17 µs 530 µs ~31×
2 000 × 16 34 µs 2.55 ms ~76×
5 000 × 16 101 µs 6.54 ms ~65×
10 000 × 16 193 µs 12.79 ms ~66×

The deep clone scales with total slots; COW eliminates the per-slot copy and tracks accounts + changed. overlay_fanout reset-recycled is a modest win that grows with fan-out width.

Tests

tests/cow_snapshot.rs — a differential-equivalence gate: create_snapshot is asserted read-identical to create_snapshot_deep_clone after 12 mutation kinds (layer-2-only write-through, same-length inject overwrite, simulated uncontrolled backend growth, purge, set_block, NotExisting, …), plus COW non-aliasing, overlay reset()/buffer-reuse, and an escape-hatch guard. 328 passed (default) / 275 (--no-default-features); existing snapshot/overlay/freshness contracts pass unchanged. fmt + clippy (both configs) + doc + bench --no-run clean.

Review

Line-by-line overseer review + a 3-way adversarial panel (staleness / read-equivalence / concurrency) confirmed the core model sound (read-equivalence bit-for-bit; the COW rebuild never mutates a shared Arc; the count-based growth scan is sufficient because the lazy fetch is append-only — validated against foundry-fork-db's source). Remediation applied:

  1. The one layer-2 write site not following the uniform rule (override_account_code…) now dirties the base.
  2. The one residual edge — an out-of-band same-length overwrite through the public blockchain_db()/backend() handles (a contract boundary, not reachable internally) — now has a public invalidate_snapshot_base() re-honest hook, rustdoc warnings on both accessors, a load-bearing-invariant note in refresh_base, a guard test, and a KNOWN_ISSUES.md entry.

Docs

CHANGELOG (### Added COW + reset(); ### Changed create_snapshot/inject_storage_batch&mut self), ROADMAP (Phase 5 → Done), KNOWN_ISSUES (limitation resolved + the boundary documented).

🤖 Generated with Claude Code

KaiCode2 and others added 2 commits June 16, 2026 16:24
docs/phase-5-spec.md is the build contract for the memoized-immutable-base
snapshot design + overlay buffer/instance reuse, with locked decisions D1-D5.
tests/cow_snapshot.rs is the red gate: a differential-equivalence property
(create_snapshot must be read-indistinguishable from a retained
create_snapshot_deep_clone reference after every mutation kind) plus the
overlay reset()/buffer-reuse contract. Red until the implementation lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the O(total state) deep-clone create_snapshot with a two-tier
copy-on-write snapshot: the cold layer-2 BlockchainDb index is flattened once
into an immutable Arc<BaseState> (per-account storage shared by Arc), memoized
across snapshots and rebuilt copy-on-write only for changed addresses; each
snapshot folds just the hot layer-1 CacheDB delta over a cheap Arc::clone. Reads
stay O(1) and lock-free (no persistent-map dep, D1); EvmSnapshot stays Send+Sync.
create_snapshot is now &mut self (D5); create_snapshot_deep_clone is retained as
the A/B baseline and the differential read-equivalence reference (D3). Every
controlled layer-2 write marks the base dirty; an O(accounts) length-scan catches
the append-only lazy-fetch growth.

Overlay reuse (D4): EvmOverlay::reset() recycles an overlay across sims, and the
64KB shared-memory buffer is reused across calls via a Send-preserving take/reclaim
(plain Vec field, method-local Rc) instead of re-allocating per build.

Indicative: create_snapshot ~30-60x faster than the deep clone on the cold-index
sweep; reset()-recycled fan-out beats fresh-overlay.

Overseer review + adversarial-panel remediation:
- mark_base_dirty in override_account_code_with_missing_target (D2 uniformity).
- invalidate_snapshot_base() public re-honest hook + rustdoc warnings on
  blockchain_db()/backend() + a load-bearing-invariant note in refresh_base, for
  the one residual edge (an out-of-band same-length layer-2 overwrite through the
  public escape hatches); pinned by a guard test and recorded in KNOWN_ISSUES.

Tests: 328 (default) / 275 (--no-default-features), incl. the cow_snapshot
differential gate (COW == deep-clone after every mutation kind) and overlay-reuse
contract. fmt + clippy (both configs) + doc + bench --no-run clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
KaiCode2 and others added 8 commits June 17, 2026 09:40
The per-context EVM working-memory buffer was hardcoded to 64 KB in two places
(EvmCache + EvmOverlay), tuned for a state-heavy upstream workload. Make it a
first-class knob:

- `SharedMemoryCapacity { Fixed(usize), Auto }`, default `Fixed(64_000)`,
  configured via `EvmCacheBuilder::shared_memory_capacity`. `Fixed` pins the size
  (general users running wide fan-outs of small sims can lower it to cut
  per-overlay memory); `Auto` sizes from the chain state loaded at build time
  (e.g. a bincode state file) — `loaded_slots * 16`, clamped to a 64 KB floor /
  4 MiB ceiling.
- Resolution happens in the new `with_cache_capacity` constructor (the builder's
  worker; `with_cache`/`new`/`from_backend` keep their signatures, defaulting to
  Fixed(64_000)). `Auto` reads the post-load layer-2 slot count, so it captures
  the maintain-list filter and any source, not just the raw file.
- The resolved size is stored on EvmCache, exposed via
  `EvmCache::shared_memory_capacity()`, raised by `reserve_shared_memory`, and
  copied onto every EvmSnapshot so snapshot-backed EvmOverlays pre-allocate the
  same amount (overlay gains a `buffer_capacity` field; the hardcoded overlay
  constant is removed).

Tests: a `resolve` heuristic unit test (floor/linear/ceiling, both feature
configs) and `tests/shared_memory_capacity.rs` end-to-end over the builder
(default, Fixed, Auto-with-no-state floor, and Auto sizing 10k loaded slots →
160_000). Full suite 335 (default) / 282 (--no-default-features); fmt + clippy
(both configs) + doc + bench --no-run clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`dropping_speculative_sim_aborts_before_queueing_correction` assumed the
SpeculativeSim's drop-abort would win a race against the spawned multi-thread
validator's first poll, which fails intermittently under full-suite parallel
load. Replace the racy "called" atomic flag with a `Gate` (Mutex + Condvar): the
fetcher blocks until the test releases the gate, and the test releases it only
*after* `drop(sim)` sets the cancel flag. So the validator's fetch — and thus its
post-fetch, correction-queuing checkpoint — can only complete once cancellation
is already observable, regardless of scheduler interleaving. Drops the over-strict
"fetcher never reached" assertion (the product guarantees a cancel seen at a
checkpoint suppresses side effects, not that an in-flight fetch is skipped) and
keeps the real invariants: no correction queued, no re-run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses three PR-review findings on the copy-on-write snapshot:

- P2 (correctness): refresh_base's Case-4 partial rebuild cloned the previous
  code_by_hash and only added refreshed dirty-account codes, so a purged or
  recoded account left a stale hash. A direct EvmOverlay::code_by_hash(old_hash)
  then returned removed bytecode while create_snapshot_deep_clone (which rebuilds
  the index from current accounts) returned none — a read-equivalence violation
  and a slow memory leak. Fix: rebuild the index from the refreshed accounts via a
  shared `code_index` helper used by both build_base_full and the Case-4 path, so
  the two stay in lockstep; handles shared hashes (a hash survives iff some present
  account still carries it) and prunes unreferenced ones.

- P3 (coverage): the differential gate now also compares code_by_hash for each
  probed account's code hash, and a new regression test
  (cow_code_index_matches_deep_clone_after_base_account_recoded) warms the base
  with bytecode, recodes the account, dirties it via a controlled per-address
  write (Case-4 partial rebuild), and asserts the old hash no longer resolves.
  Verified red against the pre-fix code.

- P3 (docs): create_snapshot rustdoc no longer claims it "merges both layers into
  a single flat HashMap"; it now describes the memoized layer-2 base + layer-1
  overlay fold and the &mut self receiver.

Tests 329 (default) / 276 (--no-default-features); fmt + clippy (both configs) +
doc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deflake the drop-abort freshness test with a deterministic gate
Configurable EVM shared-memory pre-allocation (SharedMemoryCapacity)
@KaiCode2
KaiCode2 merged commit 66213c5 into phase-3-state-updates Jun 17, 2026
2 checks passed
@KaiCode2
KaiCode2 deleted the phase-5-cow-snapshots branch June 30, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant