From b2d92828d772767fe9585d510ab2a72051f203b3 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 7 Jul 2026 12:30:10 +0800 Subject: [PATCH] fix(evm): bound store-cache depth amplification from stacked EVM snapshots Each successful EVM call frame stacks a new nested CacheMultiStore layer (DBImpl.Snapshot), and geth never signals success so the layers are never discarded. After N sequential successful calls the current context sits atop ~N nested cachekv layers, and Cosmos reads at that depth (e.g. the distribution `rewards()` precompile -> IterateDelegations + per-delegation reward math) walk every layer. That makes a linear number of reads O(N^2) in wall-clock while gas stays linear. Fix, entirely opt-in so non-EVM code is unchanged: - cachekv / giga store: add frozen+dirty state, Freeze()/Unfreeze(), and a memoized readThroughParent() that skips runs of frozen empty layers on the Get path. iterator() also returns the parent iterator directly when the layer is empty in-range (closing the unused cache iterator), collapsing the cacheMergeIterator chain. Reads become O(1) in depth instead of O(depth). - cachemulti: propagate Freeze()/Unfreeze() to sub-stores via a shared flag so lazily-materialized stores inherit the state. - DBImpl.Snapshot (x/evm/state and the giga fork) freezes the just-superseded layer, never the base layer (snapshottedCtxs[0], the flush target read by GetCommittedState). RevertToSnapshot unfreezes the re-exposed layer so a writable top is never treated as a skippable frozen layer. Safety: a layer only takes writes while it is the newest layer; skips are gated on `frozen && !dirty`, so a re-exposed-and-written layer is always consulted again. Benchmark: deep-stack Get goes from ~16us (depth 1024) to a flat ~134ns. Co-Authored-By: Claude Fable 5 --- giga/deps/store/cachekv.go | 52 +++- giga/deps/store/frozen_skip_test.go | 57 +++++ giga/deps/xevm/state/state.go | 20 +- sei-cosmos/store/cachekv/frozen_skip_test.go | 241 +++++++++++++++++++ sei-cosmos/store/cachekv/store.go | 101 +++++++- sei-cosmos/store/cachemulti/store.go | 76 +++++- x/evm/state/state.go | 24 +- 7 files changed, 564 insertions(+), 7 deletions(-) create mode 100644 giga/deps/store/frozen_skip_test.go create mode 100644 sei-cosmos/store/cachekv/frozen_skip_test.go diff --git a/giga/deps/store/cachekv.go b/giga/deps/store/cachekv.go index 9ee5f20c37..d38f0b9615 100644 --- a/giga/deps/store/cachekv.go +++ b/giga/deps/store/cachekv.go @@ -6,6 +6,7 @@ import ( "io" "sort" "sync" + "sync/atomic" "github.com/sei-protocol/sei-chain/sei-cosmos/store/tracekv" "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" @@ -21,6 +22,14 @@ type Store struct { parent types.KVStore storeKey types.StoreKey cacheSize int + + // frozen/dirty/readParent implement the same "skip frozen empty layers on + // read" optimization as sei-cosmos/store/cachekv, so that a deep stack of + // empty EVM snapshot layers does not make each Get O(depth). See Freeze and + // readThroughParent there for the full invariant. + frozen atomic.Bool + dirty atomic.Bool + readParent atomic.Pointer[types.KVStore] } var _ types.CacheKVStore = (*Store)(nil) @@ -50,7 +59,45 @@ func (store *Store) getFromCache(key []byte) []byte { if cv, ok := store.cache.Load(UnsafeBytesToStr(key)); ok { return cv.(*types.CValue).Value() } - return store.parent.Get(key) + return store.readThroughParent().Get(key) +} + +// readThroughParent returns the store a cache-missing read must fall through to, +// skipping any run of frozen empty layers. See the equivalently named method in +// sei-cosmos/store/cachekv for the full correctness argument; the invariant is +// identical (a frozen empty layer never gains writes, and RevertToSnapshot +// discards any layer that memoized a skip over a re-exposed one). +func (store *Store) readThroughParent() types.KVStore { + cp, ok := store.parent.(*Store) + if !ok || !cp.frozen.Load() || cp.dirty.Load() { + return store.parent + } + if p := store.readParent.Load(); p != nil { + return *p + } + p := store.parent + for { + next, ok := p.(*Store) + if !ok || !next.frozen.Load() || next.dirty.Load() { + break + } + p = next.parent + } + store.readParent.Store(&p) + return p +} + +// Freeze marks the store as superseded by a newer cache layer so deeper layers +// may skip it for reads while it is empty. Opt-in and idempotent. +func (store *Store) Freeze() { + store.frozen.Store(true) +} + +// Unfreeze reverts Freeze, marking the store writable again (e.g. when a layer is +// re-exposed by RevertToSnapshot). Deeper stores gate on the parent's live frozen +// bit, so unfreezing here also bypasses any stale memo that skipped this store. +func (store *Store) Unfreeze() { + store.frozen.Store(false) } // Get implements types.KVStore. @@ -115,6 +162,8 @@ func (store *Store) Write() { store.cache = &sync.Map{} store.deleted = &sync.Map{} + store.dirty.Store(false) + store.readParent.Store(nil) } // CacheWrap implements CacheWrapper. @@ -142,6 +191,7 @@ func (store *Store) setCacheValue(key, value []byte, deleted bool, dirty bool) { } else { store.deleted.Delete(keyStr) } + store.dirty.Store(true) } func (store *Store) isDeleted(key string) bool { diff --git a/giga/deps/store/frozen_skip_test.go b/giga/deps/store/frozen_skip_test.go new file mode 100644 index 0000000000..3b641bb68e --- /dev/null +++ b/giga/deps/store/frozen_skip_test.go @@ -0,0 +1,57 @@ +package store_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + dbm "github.com/tendermint/tm-db" + + gigastore "github.com/sei-protocol/sei-chain/giga/deps/store" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/dbadapter" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" +) + +func bz(s string) []byte { return []byte(s) } + +// TestGigaFrozenEmptyLayerSkip verifies that Get through a deep stack of frozen, +// empty giga cache layers returns exactly the base value (the giga store is used +// for the evm/bank stores, so EVM SLOAD at call depth N would otherwise walk N +// layers). +func TestGigaFrozenEmptyLayerSkip(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + mem.Set(bz("k1"), bz("v1")) + mem.Set(bz("k2"), bz("v2")) + + var parent types.KVStore = mem + layers := make([]*gigastore.Store, 32) + for i := 0; i < 32; i++ { + s := gigastore.NewStore(parent, types.NewKVStoreKey("giga"), types.DefaultCacheSizeLimit) + layers[i] = s + parent = s + } + for i := 0; i < len(layers)-1; i++ { + layers[i].Freeze() + } + top := layers[len(layers)-1] + + require.Equal(t, bz("v1"), top.Get(bz("k1"))) + require.Equal(t, bz("v2"), top.Get(bz("k2"))) + require.Nil(t, top.Get(bz("missing"))) +} + +// TestGigaFrozenLayerWithWriteNotSkipped verifies a frozen giga layer with a write +// (or delete) still shadows the base and is never skipped. +func TestGigaFrozenLayerWithWriteNotSkipped(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + mem.Set(bz("k1"), bz("v1")) + mem.Set(bz("k2"), bz("v2")) + + mid := gigastore.NewStore(mem, types.NewKVStoreKey("giga"), types.DefaultCacheSizeLimit) + mid.Set(bz("k1"), bz("override")) + mid.Delete(bz("k2")) + mid.Freeze() + + top := gigastore.NewStore(mid, types.NewKVStoreKey("giga"), types.DefaultCacheSizeLimit) + require.Equal(t, bz("override"), top.Get(bz("k1"))) + require.Nil(t, top.Get(bz("k2"))) +} diff --git a/giga/deps/xevm/state/state.go b/giga/deps/xevm/state/state.go index e1a8d15b96..b96fe41ff2 100644 --- a/giga/deps/xevm/state/state.go +++ b/giga/deps/xevm/state/state.go @@ -113,7 +113,18 @@ func (s *DBImpl) AnySelfDestructed() bool { } func (s *DBImpl) Snapshot() int { - newCtx := s.ctx.WithMultiStore(s.ctx.MultiStore().CacheMultiStore()).WithEventManager(sdk.NewEventManager()) + oldMS := s.ctx.MultiStore() + newCtx := s.ctx.WithMultiStore(oldMS.CacheMultiStore()).WithEventManager(sdk.NewEventManager()) + // Freeze the superseded layer so deeper layers can skip it for reads while it + // stays empty; this keeps a Cosmos read at call depth N from walking all N + // nested cache layers (quadratic). Never freeze the base layer + // (snapshottedCtxs[0]) — it is the flush target and is read directly by + // GetCommittedState. See x/evm/state/state.go for the full rationale. + if len(s.snapshottedCtxs) > 0 { + if f, ok := oldMS.(interface{ Freeze() }); ok { + f.Freeze() + } + } s.snapshottedCtxs = append(s.snapshottedCtxs, s.ctx) s.ctx = newCtx version := len(s.snapshottedCtxs) - 1 @@ -130,6 +141,13 @@ func (s *DBImpl) RevertToSnapshot(rev int) { s.ctx = s.snapshottedCtxs[rev] s.snapshottedCtxs = s.snapshottedCtxs[:rev] + // The re-exposed layer is the writable top again; unfreeze it so reads no + // longer skip it (Snapshot froze it when it was superseded). See + // x/evm/state/state.go for the rationale. + if f, ok := s.ctx.MultiStore().(interface{ Unfreeze() }); ok { + f.Unfreeze() + } + // Find the watermark index to truncate the journal watermarkIndex := -1 for i := len(s.journal) - 1; i >= 0; i-- { diff --git a/sei-cosmos/store/cachekv/frozen_skip_test.go b/sei-cosmos/store/cachekv/frozen_skip_test.go new file mode 100644 index 0000000000..372d7c58c4 --- /dev/null +++ b/sei-cosmos/store/cachekv/frozen_skip_test.go @@ -0,0 +1,241 @@ +package cachekv_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + dbm "github.com/tendermint/tm-db" + + "github.com/sei-protocol/sei-chain/sei-cosmos/store/cachekv" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/dbadapter" + "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" +) + +// stack builds n nested cachekv layers on top of base and returns them +// bottom-first (result[0] is the deepest layer over base, result[n-1] is the top). +func stack(base types.KVStore, n int) []*cachekv.Store { + layers := make([]*cachekv.Store, n) + parent := base + for i := 0; i < n; i++ { + s := cachekv.NewStore(parent, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + layers[i] = s + parent = s + } + return layers +} + +// freezeAllButTop freezes every layer except the topmost, mirroring how the EVM +// snapshot stack freezes a layer once a newer one is stacked on top of it. +func freezeAllButTop(layers []*cachekv.Store) { + for i := 0; i < len(layers)-1; i++ { + layers[i].Freeze() + } +} + +// TestFrozenEmptyLayerSkipEquivalence verifies that reads through a deep stack of +// frozen, empty cache layers return exactly what a read against the base returns. +func TestFrozenEmptyLayerSkipEquivalence(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + for i := 0; i < 20; i++ { + mem.Set(keyFmt(i), valFmt(i)) + } + + layers := stack(mem, 64) + freezeAllButTop(layers) + top := layers[len(layers)-1] + + // Point reads through the empty frozen stack must match the base exactly. + for i := 0; i < 20; i++ { + require.Equal(t, valFmt(i), top.Get(keyFmt(i)), "key %d", i) + } + require.Nil(t, top.Get(bz("missing"))) + require.True(t, top.Has(keyFmt(0))) + require.False(t, top.Has(bz("missing"))) + + // Full iteration through the empty frozen stack must yield every base key. + itr := top.Iterator(nil, nil) + got := 0 + for ; itr.Valid(); itr.Next() { + require.Equal(t, keyFmt(got), itr.Key()) + require.Equal(t, valFmt(got), itr.Value()) + got++ + } + require.NoError(t, itr.Close()) + require.Equal(t, 20, got) + + // Reverse iteration too. + ritr := top.ReverseIterator(nil, nil) + rgot := 0 + for ; ritr.Valid(); ritr.Next() { + exp := 19 - rgot + require.Equal(t, keyFmt(exp), ritr.Key()) + require.Equal(t, valFmt(exp), ritr.Value()) + rgot++ + } + require.NoError(t, ritr.Close()) + require.Equal(t, 20, rgot) +} + +// TestFrozenLayerWithWritesIsNotSkipped verifies that a frozen layer holding a +// write (a set or a delete) still shadows the base — it must never be skipped. +func TestFrozenLayerWithWritesIsNotSkipped(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + mem.Set(keyFmt(1), valFmt(1)) + mem.Set(keyFmt(2), valFmt(2)) + mem.Set(keyFmt(3), valFmt(3)) + + layers := stack(mem, 8) + + // A middle layer overrides key 1 and deletes key 2, then is frozen (dirty). + mid := layers[3] + mid.Set(keyFmt(1), valFmt(100)) + mid.Delete(keyFmt(2)) + freezeAllButTop(layers) + + top := layers[len(layers)-1] + + // Point reads must reflect the middle layer, not the base. + require.Equal(t, valFmt(100), top.Get(keyFmt(1)), "shadowed set must win") + require.Nil(t, top.Get(keyFmt(2)), "delete in a frozen layer must hide the base key") + require.Equal(t, valFmt(3), top.Get(keyFmt(3))) + + // Iteration must reflect the delete and the shadowing set. + itr := top.Iterator(nil, nil) + seen := map[string]string{} + for ; itr.Valid(); itr.Next() { + seen[string(itr.Key())] = string(itr.Value()) + } + require.NoError(t, itr.Close()) + require.Equal(t, map[string]string{ + string(keyFmt(1)): string(valFmt(100)), + string(keyFmt(3)): string(valFmt(3)), + }, seen) +} + +// TestSkipRecomputedAfterFrozenLayerBecomesDirty covers the RevertToSnapshot +// re-exposure case at the store level: a layer that was frozen-empty (and thus +// skippable) later receives writes, and any child created afterwards must consult +// it. In the real EVM flow, children created before the write are discarded by the +// revert, so no already-memoized skip can go stale. +func TestSkipRecomputedAfterFrozenLayerBecomesDirty(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + mem.Set(keyFmt(1), valFmt(1)) + + frozenEmpty := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + frozenEmpty.Freeze() + + // Child created while the layer is frozen+empty skips it and reads the base. + early := cachekv.NewStore(frozenEmpty, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + require.Equal(t, valFmt(1), early.Get(keyFmt(1))) + + // The layer is re-exposed (as after a revert) and written to. + frozenEmpty.Set(keyFmt(1), valFmt(2)) + + // A child created after the write must observe it (skip must not apply). + late := cachekv.NewStore(frozenEmpty, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + require.Equal(t, valFmt(2), late.Get(keyFmt(1))) +} + +// TestRevertWriteSnapshotSequence models the exact DBImpl flow that motivated the +// Unfreeze-on-revert fix: RevertToSnapshot re-exposes a frozen layer, execution +// writes to it, then Snapshot re-freezes it and stacks a new layer. A read from +// the new top must observe the post-revert write for the written key while still +// short-circuiting reads of keys the layer does not hold. +func TestRevertWriteSnapshotSequence(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + mem.Set(keyFmt(1), valFmt(1)) + mem.Set(keyFmt(2), valFmt(2)) + + // L is a snapshot layer that was frozen when it was superseded. + L := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + L.Freeze() + + // (1) RevertToSnapshot re-exposes L as the writable top: unfreeze it. + L.Unfreeze() + // (2) Execution writes key 1 into the re-exposed layer. + L.Set(keyFmt(1), valFmt(100)) + // (3) Snapshot stacks a new layer and re-freezes L. + L.Freeze() + C := cachekv.NewStore(L, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + + // The written key reflects the post-revert write (L is dirty, not skipped); + // the untouched key still resolves through to the base. + require.Equal(t, valFmt(100), C.Get(keyFmt(1))) + require.Equal(t, valFmt(2), C.Get(keyFmt(2))) +} + +// TestUnfreezeBypassesStaleSkipMemo covers the concurrent-copy hazard: a store +// memoizes a skip over a frozen-empty parent, then that parent is re-exposed +// (unfrozen) and written. Because reads gate on the parent's live frozen bit, the +// same store must observe the write despite its stale memo. +func TestUnfreezeBypassesStaleSkipMemo(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + mem.Set(keyFmt(1), valFmt(1)) + + parent := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + parent.Freeze() + + child := cachekv.NewStore(parent, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + // Read while parent is frozen+empty: child memoizes a skip over parent. + require.Equal(t, valFmt(1), child.Get(keyFmt(1))) + + // Parent is re-exposed and written (the case the single-DBImpl revert would + // otherwise discard the child for, but a live Copy would not). + parent.Unfreeze() + parent.Set(keyFmt(1), valFmt(2)) + + // The child must now see the write even though it memoized a skip earlier. + require.Equal(t, valFmt(2), child.Get(keyFmt(1))) +} + +// TestWriteClearsFrozenSkipState verifies Write() resets emptiness bookkeeping so +// a flushed layer is treated as empty again. +func TestWriteClearsFrozenSkipState(t *testing.T) { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + mem.Set(keyFmt(1), valFmt(1)) + + a := cachekv.NewStore(mem, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + a.Set(keyFmt(2), valFmt(2)) // a is dirty + a.Freeze() + a.Write() // flushes into mem and clears dirty + + b := cachekv.NewStore(a, types.NewKVStoreKey("CacheKvTest"), types.DefaultCacheSizeLimit) + // a is frozen and empty again after Write; b must still read through correctly. + require.Equal(t, valFmt(1), b.Get(keyFmt(1))) + require.Equal(t, valFmt(2), b.Get(keyFmt(2))) +} + +// buildEmptyStackOverBase is a benchmark helper. +func buildEmptyStackOverBase(depth int, freeze bool) *cachekv.Store { + mem := dbadapter.Store{DB: dbm.NewMemDB()} + for i := 0; i < 50; i++ { + mem.Set(keyFmt(i), valFmt(i)) + } + layers := stack(mem, depth) + if freeze { + freezeAllButTop(layers) + } + return layers[len(layers)-1] +} + +// BenchmarkDeepStackGet demonstrates that freezing empty layers keeps a read O(1) +// in stack depth instead of O(depth). +func BenchmarkDeepStackGet(b *testing.B) { + for _, depth := range []int{16, 256, 1024} { + b.Run(fmt.Sprintf("depth=%d/frozen", depth), func(b *testing.B) { + top := buildEmptyStackOverBase(depth, true) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = top.Get(keyFmt(i % 50)) + } + }) + b.Run(fmt.Sprintf("depth=%d/unfrozen", depth), func(b *testing.B) { + top := buildEmptyStackOverBase(depth, false) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = top.Get(keyFmt(i % 50)) + } + }) + } +} diff --git a/sei-cosmos/store/cachekv/store.go b/sei-cosmos/store/cachekv/store.go index 920ee9a076..da09b01478 100644 --- a/sei-cosmos/store/cachekv/store.go +++ b/sei-cosmos/store/cachekv/store.go @@ -5,6 +5,7 @@ import ( "io" "sort" "sync" + "sync/atomic" "github.com/sei-protocol/sei-chain/sei-cosmos/internal/conv" "github.com/sei-protocol/sei-chain/sei-cosmos/store/tracekv" @@ -23,6 +24,20 @@ type Store struct { parent types.KVStore storeKey types.StoreKey cacheSize int + + // frozen marks a layer that has been superseded by a newer cache layer and + // will therefore never receive another write via this store reference (writes + // go to the newest layer). It is set opt-in via Freeze() — callers that never + // call Freeze (all non-EVM code) get exactly the previous behavior. + frozen atomic.Bool + // dirty is set the first time a key is written (Set/Delete) and cleared on + // Write(). A frozen layer with dirty==false is empty and, by the freeze + // invariant, stays empty, so reads may skip it entirely. + dirty atomic.Bool + // readParent memoizes the nearest ancestor a read must consult, skipping any + // run of frozen empty layers. Valid for the store's lifetime because a frozen + // empty layer never gains writes (see readThroughParent). + readParent atomic.Pointer[types.KVStore] } var _ types.CacheKVStore = (*Store)(nil) @@ -54,7 +69,58 @@ func (store *Store) getFromCache(key []byte) []byte { if cv, ok := store.cache.Load(conv.UnsafeBytesToStr(key)); ok { return cv.(*types.CValue).Value() } - return store.parent.Get(key) + return store.readThroughParent().Get(key) +} + +// readThroughParent returns the store a cache-missing read must fall through to. +// It normally returns store.parent, but when the parent is a frozen empty cache +// layer it walks up, skipping every consecutive frozen empty layer, and returns +// the first ancestor that could actually hold data. Empty layers contribute +// nothing to a point read (getFromCache falls straight through them), so the +// result is identical to walking one layer at a time — only O(1) instead of +// O(depth) once a deep stack of empty snapshot layers has accumulated. +// +// The result is safe to memoize: a layer is only skipped when it is frozen AND +// empty, and a frozen layer never gains writes (writes always target the newest, +// unfrozen layer). The single exception is RevertToSnapshot re-exposing a layer +// as the newest layer, but that discards every layer above it — including any +// store that memoized a skip over it — so no live memo can go stale. +func (store *Store) readThroughParent() types.KVStore { + // Cheap gate: only nested cache layers can be skipped. The common single-layer + // case (parent is an iavl/dbadapter store) never enters the skip path. + cp, ok := store.parent.(*Store) + if !ok || !cp.frozen.Load() || cp.dirty.Load() { + return store.parent + } + if p := store.readParent.Load(); p != nil { + return *p + } + p := store.parent + for { + next, ok := p.(*Store) + if !ok || !next.frozen.Load() || next.dirty.Load() { + break + } + p = next.parent + } + store.readParent.Store(&p) + return p +} + +// Freeze marks the store as superseded by a newer cache layer. After Freeze the +// caller must not write to the store again (writes go to the newer layer); this +// lets deeper layers skip it for reads while it is empty. Freeze is idempotent. +func (store *Store) Freeze() { + store.frozen.Store(true) +} + +// Unfreeze reverts Freeze, marking the store writable again. It is called when a +// layer is re-exposed as the newest layer (e.g. RevertToSnapshot), so that reads +// stop skipping it — a writable top must never be treated as a frozen empty layer. +// Deeper stores gate on the parent's live frozen bit, so unfreezing here also +// bypasses any stale memo that skipped this store while it was frozen. +func (store *Store) Unfreeze() { + store.frozen.Store(false) } // Get implements types.KVStore. @@ -121,6 +187,10 @@ func (store *Store) Write() { store.deleted = &sync.Map{} store.unsortedCache = &sync.Map{} store.sortedCache = nil + // The layer is empty again; drop the memoized skip so a subsequent read + // recomputes it against the current parent chain. + store.dirty.Store(false) + store.readParent.Store(nil) } // CacheWrap implements CacheWrapper. @@ -160,10 +230,15 @@ func (store *Store) iterator(start, end []byte, ascending bool) types.Iterator { var parent, cache types.Iterator + // Iterate the nearest ancestor that can hold data, skipping any run of frozen + // empty layers. An empty layer has no sets and no deletes, so it contributes + // nothing to iteration; skipping it avoids building an O(depth) chain of + // cacheMergeIterators over a deep snapshot stack. + parentStore := store.readThroughParent() if ascending { - parent = store.parent.Iterator(start, end) + parent = parentStore.Iterator(start, end) } else { - parent = store.parent.ReverseIterator(start, end) + parent = parentStore.ReverseIterator(start, end) } defer func() { if err := recover(); err != nil { @@ -176,6 +251,24 @@ func (store *Store) iterator(start, end []byte, ascending bool) types.Iterator { }() store.dirtyItems(start, end) cache = newMemIterator(start, end, store.getOrInitSortedCache(), store.deleted, ascending) + // Fast path: when this layer has no cached writes or deletes within [start, end), + // the merge is a pure pass-through of the parent iterator. Returning the parent + // directly avoids wrapping every empty cache layer in a cacheMergeIterator. Deeply + // nested cache stacks (e.g. one CacheMultiStore layer per EVM call frame) would + // otherwise force each Value()/Next()/Valid() to recurse through O(depth) merge + // iterators, turning a linear number of reads into quadratic work. Because empty + // layers pass through, nested empty layers collapse the whole chain to the base + // iterator. Note dirtyItems materializes in-range deletes into sortedCache, so a + // non-Valid cache correctly means "no sets and no deletes in range". + if !cache.Valid() { + if err := cache.Close(); err != nil { + if parent != nil { + _ = parent.Close() + } + panic(err) + } + return parent + } return NewCacheMergeIterator(parent, cache, ascending, store.storeKey) } @@ -341,6 +434,8 @@ func (store *Store) setCacheValue(key, value []byte, deleted bool, dirty bool) { if dirty { store.unsortedCache.Store(keyStr, struct{}{}) } + // Mark the layer non-empty so deeper layers stop skipping it for reads. + store.dirty.Store(true) } func (store *Store) isDeleted(key string) bool { diff --git a/sei-cosmos/store/cachemulti/store.go b/sei-cosmos/store/cachemulti/store.go index d6f0e8f93c..627d5a39f0 100644 --- a/sei-cosmos/store/cachemulti/store.go +++ b/sei-cosmos/store/cachemulti/store.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "sync" + "sync/atomic" dbm "github.com/tendermint/tm-db" @@ -14,6 +15,14 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" ) +// freezable is implemented by cache stores that can be marked as superseded by a +// newer layer so that deeper layers may skip them for reads while they are empty, +// and reverted back to writable when a layer is re-exposed. +type freezable interface { + Freeze() + Unfreeze() +} + //---------------------------------------- // Store @@ -36,6 +45,11 @@ type Store struct { mu *sync.RWMutex // protects stores and parents during lazy creation materializeOnce *sync.Once + // frozen marks this layer as superseded by a newer cache layer. Set opt-in via + // Freeze(); shared (pointer) across value copies of the same layer so that + // stores materialized lazily after freezing are also frozen. + frozen *atomic.Bool + closers []io.Closer } @@ -77,6 +91,7 @@ func newStoreWithoutGiga(store types.KVStore, stores map[types.StoreKey]types.Ca traceContext: traceContext, mu: &sync.RWMutex{}, materializeOnce: &sync.Once{}, + frozen: &atomic.Bool{}, closers: []io.Closer{}, } @@ -103,6 +118,7 @@ func newCacheMultiStoreFromCMS(cms Store) Store { cms.materializeOnce.Do(func() { // Lock held for bulk materialization to avoid per-key lock overhead. cms.mu.Lock() + frozen := cms.frozen != nil && cms.frozen.Load() for k := range cms.parents { // Inline the creation here — we already hold the write lock. parent := cms.parents[k] @@ -110,7 +126,11 @@ func newCacheMultiStoreFromCMS(cms Store) Store { if cms.TracingEnabled() { cw = tracekv.NewStore(parent.(types.KVStore), cms.traceWriter, cms.traceContext) } - cms.stores[k] = cachekv.NewStore(cw.(types.KVStore), k, types.DefaultCacheSizeLimit) + s := cachekv.NewStore(cw.(types.KVStore), k, types.DefaultCacheSizeLimit) + if frozen { + s.Freeze() + } + cms.stores[k] = s delete(cms.parents, k) } cms.mu.Unlock() @@ -161,11 +181,65 @@ func (cms Store) getOrCreateStore(key types.StoreKey) types.CacheWrap { cw = tracekv.NewStore(parent.(types.KVStore), cms.traceWriter, cms.traceContext) } s := cachekv.NewStore(cw.(types.KVStore), key, types.DefaultCacheSizeLimit) + if cms.frozen != nil && cms.frozen.Load() { + s.Freeze() + } cms.stores[key] = s delete(cms.parents, key) return s } +// Freeze marks the layer (and every materialized cache store within it) as +// superseded by a newer cache layer, so deeper layers may skip it for reads while +// it is empty. Stores materialized after Freeze inherit the frozen state via the +// shared flag. Freeze is opt-in and idempotent; layers that are never frozen keep +// the prior behavior exactly. +func (cms Store) Freeze() { + if cms.frozen != nil { + cms.frozen.Store(true) + } + if f, ok := cms.db.(freezable); ok { + f.Freeze() + } + cms.mu.RLock() + for _, s := range cms.stores { + if f, ok := s.(freezable); ok { + f.Freeze() + } + } + cms.mu.RUnlock() + for _, s := range cms.gigaStores { + if f, ok := s.(freezable); ok { + f.Freeze() + } + } +} + +// Unfreeze reverts Freeze for the layer and every materialized cache store within +// it. It is called when the layer is re-exposed as the newest (writable) layer, +// e.g. by RevertToSnapshot, so that it is no longer skipped for reads. Stores +// materialized after Unfreeze see the cleared flag and are not frozen. +func (cms Store) Unfreeze() { + if cms.frozen != nil { + cms.frozen.Store(false) + } + if f, ok := cms.db.(freezable); ok { + f.Unfreeze() + } + cms.mu.RLock() + for _, s := range cms.stores { + if f, ok := s.(freezable); ok { + f.Unfreeze() + } + } + cms.mu.RUnlock() + for _, s := range cms.gigaStores { + if f, ok := s.(freezable); ok { + f.Unfreeze() + } + } +} + // SetTracer sets the tracer for the MultiStore that the underlying // stores will utilize to trace operations. A MultiStore is returned. func (cms Store) SetTracer(w io.Writer) types.MultiStore { diff --git a/x/evm/state/state.go b/x/evm/state/state.go index 0a090ff326..56c06153af 100644 --- a/x/evm/state/state.go +++ b/x/evm/state/state.go @@ -107,7 +107,21 @@ func (s *DBImpl) HasSelfDestructed(acc common.Address) bool { } func (s *DBImpl) Snapshot() int { - newCtx := s.ctx.WithMultiStore(s.ctx.MultiStore().CacheMultiStore()).WithEventManager(sdk.NewEventManager()) + oldMS := s.ctx.MultiStore() + newCtx := s.ctx.WithMultiStore(oldMS.CacheMultiStore()).WithEventManager(sdk.NewEventManager()) + // The layer we just branched from is now superseded: all subsequent writes go + // to newCtx, so this layer will not change again until it is flushed (after + // execution) or discarded by a revert. Freeze it so that deeper layers can skip + // it for reads while it stays empty — without this, a Cosmos read at call depth + // N (e.g. delegation-reward queries via precompiles) walks all N nested cache + // layers, making a linear number of reads quadratic. We never freeze the base + // layer (snapshottedCtxs[0]): it is the flush target and is read directly by + // GetCommittedState, and it is not an EVM-created frame. + if len(s.snapshottedCtxs) > 0 { + if f, ok := oldMS.(interface{ Freeze() }); ok { + f.Freeze() + } + } s.snapshottedCtxs = append(s.snapshottedCtxs, s.ctx) s.ctx = newCtx version := len(s.snapshottedCtxs) - 1 @@ -124,6 +138,14 @@ func (s *DBImpl) RevertToSnapshot(rev int) { s.ctx = s.snapshottedCtxs[rev] s.snapshottedCtxs = s.snapshottedCtxs[:rev] + // The layer we just re-exposed becomes the writable top again, so it must no + // longer be treated as a frozen (skippable) layer: writes may now land here, + // and Snapshot() froze it when it was superseded. Unfreezing also drops the + // skip on any store whose parent is this layer, so reads see the new writes. + if f, ok := s.ctx.MultiStore().(interface{ Unfreeze() }); ok { + f.Unfreeze() + } + // Find the watermark index to truncate the journal watermarkIndex := -1 for i := len(s.journal) - 1; i >= 0; i-- {