Compare commits

...

5 Commits

Author SHA1 Message Date
Manu NALEPA
f2db697da2 Copy: Defer deep copy of merkle layers until mutation via lazy copy-on-write. 2026-03-10 12:14:09 +01:00
Manu NALEPA
8a8373899a Fix TransferTrie to handle sharedLayers and simplify lazy copy resolution 2026-03-10 12:11:28 +01:00
Manu NALEPA
5815d494c7 Remove unnecessary type arguments.
Golang can infer them automatically.
2026-03-10 12:11:20 +01:00
Manu NALEPA
670eda5746 CopyTrie: Defer deep copy of field layers until mutation via lazy copy-on-write. 2026-03-10 12:11:11 +01:00
Manu NALEPA
0d4892afae Periodically evict finalized states in checkpoint states cache (#16458)
**What type of PR is this?**
Bug fix

**What does this PR do? Why is it needed?**
This PR evicts finalized states in checkpoint states cache.

States are efficiently stored in caches, especially thanks to multi
value slices. If 1 state takes 300 MB and 2 states that are really
similar are stored in a cache, then these 2 states could only need let's
say 310MB of cache memory (instead of 300 MB x 2 = 600 MB).

**Before the commit creating the memory issue**, new states were
regularly stored in the `CheckpointStateCache`. This cache has 10 slots.
After this cache is full, oldest values (not quite exactly because it's
a LRU cache) are pruned.

**After the commit creating the memory issue**, new states are quite
rarely inserted into this cache. For example, on a run, almost 5H (!)
were needed before the first value was evicted from this cache. This
mean this cache contains multiple states that do not share a lot of
values with other states in all other caches/head.
==> A lot of fields stay in memory that are exclusively needed for the
(old) states only present in this cache.

The beacon node now evicts finalized states from the cache.

**Which issues(s) does this PR fix?**
- https://github.com/OffchainLabs/prysm/issues/16376

<img width="1022" height="914" alt="image"
src="https://github.com/user-attachments/assets/98886364-001a-48fc-a952-5c6a7e80bf88"
/>

**Acknowledgements**
- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description with sufficient context for reviewers
to understand this PR.
- [x] I have tested that my changes work as expected and I added a
testing plan to the PR description (if applicable).
2026-03-10 12:10:16 +01:00
17 changed files with 370 additions and 93 deletions

View File

@@ -327,6 +327,8 @@ func (s *Service) executePostFinalizationTasks(ctx context.Context, finalizedSta
}
}()
}
go s.checkpointStateCache.EvictUpTo(finalized.Epoch)
}
// ReceiveBlockBatch processes the whole block batch at once, assuming the block batch is linear ,transitioning

View File

@@ -53,6 +53,7 @@ go_library(
"//proto/prysm/v1alpha1:go_default_library",
"//proto/prysm/v1alpha1/attestation:go_default_library",
"//runtime/version:go_default_library",
"//time/slots:go_default_library",
"@com_github_ethereum_go_ethereum//common:go_default_library",
"@com_github_hashicorp_golang_lru//:go_default_library",
"@com_github_patrickmn_go_cache//:go_default_library",

View File

@@ -3,8 +3,10 @@ package cache
import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
lruwrpr "github.com/OffchainLabs/prysm/v7/cache/lru"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/crypto/hash"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/OffchainLabs/prysm/v7/time/slots"
lru "github.com/hashicorp/golang-lru"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -25,6 +27,14 @@ var (
Name: "check_point_state_cache_hit",
Help: "The number of check point state requests that are present in the cache.",
})
checkpointStateSize = promauto.NewGauge(prometheus.GaugeOpts{
Name: "check_point_state_cache_size",
Help: "The number of entries in the check point state cache.",
})
checkpointStateEvicted = promauto.NewCounter(prometheus.CounterOpts{
Name: "check_point_state_cache_evicted_total",
Help: "The number of entries evicted from the check point state cache.",
})
)
// CheckpointStateCache is a struct with 1 queue for looking up state by checkpoint.
@@ -49,14 +59,14 @@ func (c *CheckpointStateCache) StateByCheckpoint(cp *ethpb.Checkpoint) (state.Be
item, exists := c.cache.Get(h)
if exists && item != nil {
checkpointStateHit.Inc()
// Copy here is unnecessary since the return will only be used to verify attestation signature.
return item.(state.BeaconState), nil
if !exists || item == nil {
checkpointStateMiss.Inc()
return nil, nil
}
checkpointStateMiss.Inc()
return nil, nil
checkpointStateHit.Inc()
// Copy here is unnecessary since the return will only be used to verify attestation signature.
return item.(state.BeaconState), nil
}
// AddCheckpointState adds CheckpointState object to the cache. This method also trims the least
@@ -66,6 +76,35 @@ func (c *CheckpointStateCache) AddCheckpointState(cp *ethpb.Checkpoint, s state.
if err != nil {
return err
}
c.cache.Add(h, s)
checkpointStateSize.Set(float64(c.cache.Len()))
return nil
}
// EvictUpTo removes all entries from the cache whose state epoch is at
// or before the given epoch. Returns the number of evicted entries.
func (c *CheckpointStateCache) EvictUpTo(epoch primitives.Epoch) int {
evicted := 0
for _, key := range c.cache.Keys() {
// Peek is used here to avoid updating the recency of the entry,
// as we are only checking for eviction.
v, ok := c.cache.Peek(key)
if !ok {
continue
}
st := v.(state.ReadOnlyBeaconState)
if slots.ToEpoch(st.Slot()) <= epoch {
c.cache.Remove(key)
evicted++
}
}
if evicted > 0 {
checkpointStateSize.Set(float64(c.cache.Len()))
checkpointStateEvicted.Add(float64(evicted))
}
return evicted
}

View File

@@ -72,3 +72,76 @@ func TestCheckpointStateCache_MaxSize(t *testing.T) {
assert.Equal(t, cache.MaxCheckpointStateSize(), len(c.Cache().Keys()))
}
func TestCheckpointStateCache_EvictFinalized_FinalizedEntry(t *testing.T) {
c := cache.NewCheckpointStateCache()
cp := &ethpb.Checkpoint{Epoch: 1, Root: bytesutil.PadTo([]byte{'A'}, 32)}
st, err := state_native.InitializeFromProtoPhase0(&ethpb.BeaconState{Slot: 32})
require.NoError(t, err)
require.NoError(t, c.AddCheckpointState(cp, st))
evicted := c.EvictUpTo(1)
assert.Equal(t, 1, evicted, "expected finalized entry to be evicted")
s, err := c.StateByCheckpoint(cp)
require.NoError(t, err)
assert.Equal(t, state.BeaconState(nil), s, "expected cache to be empty after eviction")
}
func TestCheckpointStateCache_EvictFinalized_NotFinalizedEntry(t *testing.T) {
c := cache.NewCheckpointStateCache()
cp := &ethpb.Checkpoint{Epoch: 5, Root: bytesutil.PadTo([]byte{'A'}, 32)}
st, err := state_native.InitializeFromProtoPhase0(&ethpb.BeaconState{Slot: 160})
require.NoError(t, err)
require.NoError(t, c.AddCheckpointState(cp, st))
evicted := c.EvictUpTo(3)
assert.Equal(t, 0, evicted, "expected non-finalized entry NOT to be evicted")
s, err := c.StateByCheckpoint(cp)
require.NoError(t, err)
assert.NotNil(t, s, "expected entry to still be in cache")
}
func TestCheckpointStateCache_EvictFinalized_Mixed(t *testing.T) {
c := cache.NewCheckpointStateCache()
cp1 := &ethpb.Checkpoint{Epoch: 1, Root: bytesutil.PadTo([]byte{'A'}, 32)}
st1, err := state_native.InitializeFromProtoPhase0(&ethpb.BeaconState{Slot: 32})
require.NoError(t, err)
cp2 := &ethpb.Checkpoint{Epoch: 2, Root: bytesutil.PadTo([]byte{'B'}, 32)}
st2, err := state_native.InitializeFromProtoPhase0(&ethpb.BeaconState{Slot: 64})
require.NoError(t, err)
cp5 := &ethpb.Checkpoint{Epoch: 5, Root: bytesutil.PadTo([]byte{'C'}, 32)}
st5, err := state_native.InitializeFromProtoPhase0(&ethpb.BeaconState{Slot: 160})
require.NoError(t, err)
require.NoError(t, c.AddCheckpointState(cp1, st1))
require.NoError(t, c.AddCheckpointState(cp2, st2))
require.NoError(t, c.AddCheckpointState(cp5, st5))
evicted := c.EvictUpTo(3)
assert.Equal(t, 2, evicted, "expected epochs 1 and 2 to be evicted")
s, err := c.StateByCheckpoint(cp1)
require.NoError(t, err)
assert.Equal(t, state.BeaconState(nil), s, "expected cp1 to be evicted")
s, err = c.StateByCheckpoint(cp2)
require.NoError(t, err)
assert.Equal(t, state.BeaconState(nil), s, "expected cp2 to be evicted")
s, err = c.StateByCheckpoint(cp5)
require.NoError(t, err)
assert.NotNil(t, s, "expected cp5 to still be in cache")
}
func TestCheckpointStateCache_EvictFinalized_EmptyCache(t *testing.T) {
c := cache.NewCheckpointStateCache()
evicted := c.EvictUpTo(0)
assert.Equal(t, 0, evicted, "expected no eviction from empty cache")
}

View File

@@ -16,6 +16,8 @@ go_library(
"//math:go_default_library",
"//proto/prysm/v1alpha1:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_prometheus_client_golang//prometheus:go_default_library",
"@com_github_prometheus_client_golang//prometheus/promauto:go_default_library",
],
)

View File

@@ -9,9 +9,21 @@ import (
multi_value_slice "github.com/OffchainLabs/prysm/v7/container/multi-value-slice"
pmath "github.com/OffchainLabs/prysm/v7/math"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
fieldTrieLazyCopyCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "field_trie_lazy_copy_total",
Help: "Total number of CopyTrie calls that produced a lazy copy.",
})
fieldTrieMaterializeCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "field_trie_materialize_total",
Help: "Total number of lazy copies that were materialized due to mutation.",
})
ErrInvalidFieldTrie = errors.New("invalid field trie")
ErrEmptyFieldTrie = errors.New("empty field trie")
)
@@ -30,6 +42,7 @@ type FieldTrie struct {
*sync.RWMutex
reference *stateutil.Reference
fieldLayers [][]*[32]byte
sharedLayers [][]*[32]byte
field types.FieldIndex
dataType types.DataType
length uint64
@@ -107,6 +120,9 @@ func (f *FieldTrie) RecomputeTrie(indices []uint64, elements any) ([32]byte, err
return f.TrieRoot()
}
// Materialize from shared layers if this is a lazy copy.
f.materialize()
fieldRoots, err := fieldConverters(f.field, indices, elements, false)
if err != nil {
return [32]byte{}, err
@@ -166,26 +182,20 @@ func (f *FieldTrie) RecomputeTrie(indices []uint64, elements any) ([32]byte, err
}
}
// CopyTrie copies the references to the elements the trie
// is built on.
// CopyTrie creates a lazy copy of the trie. The underlying layers are not
// copied immediately, they are shared via sharedLayers. A full copy is
// deferred until RecomputeTrie needs to mutate the layers. If the trie is
// only read with TrieRoot, the copy cost is avoided entirely.
func (f *FieldTrie) CopyTrie() *FieldTrie {
if f.fieldLayers == nil {
return &FieldTrie{
field: f.field,
dataType: f.dataType,
reference: stateutil.NewRef(1),
RWMutex: new(sync.RWMutex),
length: f.length,
numOfElems: f.numOfElems,
}
}
dstFieldTrie := make([][]*[32]byte, len(f.fieldLayers))
for i, layer := range f.fieldLayers {
dstFieldTrie[i] = make([]*[32]byte, len(layer))
copy(dstFieldTrie[i], layer)
if f.fieldLayers != nil {
f.sharedLayers = f.fieldLayers
f.fieldLayers = nil
}
fieldTrieLazyCopyCount.Inc()
return &FieldTrie{
fieldLayers: dstFieldTrie,
sharedLayers: f.sharedLayers,
field: f.field,
dataType: f.dataType,
reference: stateutil.NewRef(1),
@@ -208,7 +218,7 @@ func (f *FieldTrie) Length() uint64 {
// us save on a copy. Any caller of this method will need
// to take care that this isn't called on an empty trie.
func (f *FieldTrie) TransferTrie() *FieldTrie {
if f.fieldLayers == nil {
if f.fieldLayers == nil && f.sharedLayers == nil {
return &FieldTrie{
field: f.field,
dataType: f.dataType,
@@ -220,13 +230,14 @@ func (f *FieldTrie) TransferTrie() *FieldTrie {
}
f.isTransferred = true
nTrie := &FieldTrie{
fieldLayers: f.fieldLayers,
field: f.field,
dataType: f.dataType,
reference: stateutil.NewRef(1),
RWMutex: new(sync.RWMutex),
length: f.length,
numOfElems: f.numOfElems,
fieldLayers: f.fieldLayers,
sharedLayers: f.sharedLayers,
field: f.field,
dataType: f.dataType,
reference: stateutil.NewRef(1),
RWMutex: new(sync.RWMutex),
length: f.length,
numOfElems: f.numOfElems,
}
// Zero out field layers here.
f.fieldLayers = nil
@@ -238,17 +249,23 @@ func (f *FieldTrie) TrieRoot() ([32]byte, error) {
if f.Empty() {
return [32]byte{}, ErrEmptyFieldTrie
}
if len(f.fieldLayers[len(f.fieldLayers)-1]) == 0 {
layers := f.sharedLayers
if f.fieldLayers != nil {
layers = f.fieldLayers
}
if len(layers[len(layers)-1]) == 0 {
return [32]byte{}, ErrInvalidFieldTrie
}
switch f.dataType {
case types.BasicArray:
return *f.fieldLayers[len(f.fieldLayers)-1][0], nil
return *layers[len(layers)-1][0], nil
case types.CompositeArray:
trieRoot := *f.fieldLayers[len(f.fieldLayers)-1][0]
return stateutil.AddInMixin(trieRoot, uint64(len(f.fieldLayers[0])))
trieRoot := *layers[len(layers)-1][0]
return stateutil.AddInMixin(trieRoot, uint64(len(layers[0])))
case types.CompressedArray:
trieRoot := *f.fieldLayers[len(f.fieldLayers)-1][0]
trieRoot := *layers[len(layers)-1][0]
return stateutil.AddInMixin(trieRoot, uint64(f.numOfElems))
default:
return [32]byte{}, errors.Errorf("unrecognized data type in field map: %v", reflect.TypeFor[types.DataType]().Name())
@@ -264,7 +281,19 @@ func (f *FieldTrie) FieldReference() *stateutil.Reference {
// Empty checks whether the underlying field trie is
// empty or not.
func (f *FieldTrie) Empty() bool {
return f == nil || len(f.fieldLayers) == 0 || f.isTransferred
if f == nil {
return true
}
if len(f.fieldLayers) == 0 && len(f.sharedLayers) == 0 {
return true
}
if f.isTransferred {
return true
}
return false
}
// InsertFieldLayer manually inserts a field layer. This method
@@ -273,3 +302,24 @@ func (f *FieldTrie) Empty() bool {
func (f *FieldTrie) InsertFieldLayer(layer [][]*[32]byte) {
f.fieldLayers = layer
}
// materialize performs the deferred deep copy from sharedLayers into
// fieldLayers. This must be called before any mutation of the trie layers.
// Caller must hold the write lock.
func (f *FieldTrie) materialize() {
if f.fieldLayers != nil || f.sharedLayers == nil {
return
}
fieldTrieMaterializeCount.Inc()
dst := make([][]*[32]byte, 0, len(f.sharedLayers))
for _, layer := range f.sharedLayers {
copiedLayer := make([]*[32]byte, len(layer))
copy(copiedLayer, layer)
dst = append(dst, copiedLayer)
}
f.fieldLayers = dst
f.sharedLayers = nil
}

View File

@@ -74,11 +74,11 @@ func fieldConverters(field types.FieldIndex, indices []uint64, elements any, con
func convertRoots(indices []uint64, elements any, convertAll bool) ([][32]byte, error) {
switch castedType := elements.(type) {
case customtypes.BlockRoots:
return handle32ByteMVslice(multi_value_slice.BuildEmptyCompositeSlice[[32]byte](castedType), indices, convertAll)
return handle32ByteMVslice(multi_value_slice.BuildEmptyCompositeSlice(castedType), indices, convertAll)
case customtypes.StateRoots:
return handle32ByteMVslice(multi_value_slice.BuildEmptyCompositeSlice[[32]byte](castedType), indices, convertAll)
return handle32ByteMVslice(multi_value_slice.BuildEmptyCompositeSlice(castedType), indices, convertAll)
case customtypes.RandaoMixes:
return handle32ByteMVslice(multi_value_slice.BuildEmptyCompositeSlice[[32]byte](castedType), indices, convertAll)
return handle32ByteMVslice(multi_value_slice.BuildEmptyCompositeSlice(castedType), indices, convertAll)
case multi_value_slice.MultiValueSliceComposite[[32]byte]:
return handle32ByteMVslice(castedType, indices, convertAll)
default:
@@ -97,7 +97,7 @@ func convertEth1DataVotes(indices []uint64, elements any, convertAll bool) ([][3
func convertValidators(indices []uint64, elements any, convertAll bool) ([][32]byte, error) {
switch casted := elements.(type) {
case []*ethpb.Validator:
return handleValidatorMVSlice(multi_value_slice.BuildEmptyCompositeSlice[*ethpb.Validator](casted), indices, convertAll)
return handleValidatorMVSlice(multi_value_slice.BuildEmptyCompositeSlice(casted), indices, convertAll)
case multi_value_slice.MultiValueSliceComposite[*ethpb.Validator]:
return handleValidatorMVSlice(casted, indices, convertAll)
default:
@@ -116,7 +116,7 @@ func convertAttestations(indices []uint64, elements any, convertAll bool) ([][32
func convertBalances(indices []uint64, elements any, convertAll bool) ([][32]byte, error) {
switch casted := elements.(type) {
case []uint64:
return handleBalanceMVSlice(multi_value_slice.BuildEmptyCompositeSlice[uint64](casted), indices, convertAll)
return handleBalanceMVSlice(multi_value_slice.BuildEmptyCompositeSlice(casted), indices, convertAll)
case multi_value_slice.MultiValueSliceComposite[uint64]:
return handleBalanceMVSlice(casted, indices, convertAll)
default:

View File

@@ -131,6 +131,9 @@ func TestNewFieldTrie_UnknownType(t *testing.T) {
}
func TestFieldTrie_CopyTrieImmutable(t *testing.T) {
// CopyTrie uses lazy copy-on-write. The production contract is:
// after CopyTrie, the COPY may be mutated while the ORIGINAL stays
// unchanged. This test verifies mutating the copy does not affect the original.
newState, _ := util.DeterministicGenesisState(t, 32)
mixes := newState.RandaoMixes()
randaoMixes := make([][32]byte, len(mixes))
@@ -138,28 +141,84 @@ func TestFieldTrie_CopyTrieImmutable(t *testing.T) {
randaoMixes[i] = [32]byte(r)
}
trie, err := NewFieldTrie(types.RandaoMixes, types.BasicArray, customtypes.RandaoMixes(randaoMixes), uint64(params.BeaconConfig().EpochsPerHistoricalVector))
originalTrie, err := NewFieldTrie(types.RandaoMixes, types.BasicArray, customtypes.RandaoMixes(randaoMixes), uint64(params.BeaconConfig().EpochsPerHistoricalVector))
require.NoError(t, err)
originalRoot, err := originalTrie.TrieRoot()
require.NoError(t, err)
newTrie := trie.CopyTrie()
copiedTrie := originalTrie.CopyTrie()
// Verify the copy initially has the same root.
copyRoot, err := copiedTrie.TrieRoot()
require.NoError(t, err)
require.Equal(t, originalRoot, copyRoot, "copy should initially have same root")
// Mutate the COPY (the production pattern).
changedIdx := []uint64{2, 29}
changedVals := [][32]byte{{'A', 'B'}, {'C', 'D'}}
require.NoError(t, newState.UpdateRandaoMixesAtIndex(changedIdx[0], changedVals[0]))
require.NoError(t, newState.UpdateRandaoMixesAtIndex(changedIdx[1], changedVals[1]))
mixes = newState.RandaoMixes()
randaoMixes = make([][32]byte, len(mixes))
for i, r := range mixes {
randaoMixes[i] = [32]byte(r)
}
root, err := trie.RecomputeTrie(changedIdx, customtypes.RandaoMixes(randaoMixes))
mutatedRoot, err := copiedTrie.RecomputeTrie(changedIdx, customtypes.RandaoMixes(randaoMixes))
require.NoError(t, err)
newRoot, err := newTrie.TrieRoot()
// The original should be unchanged.
rootAfter, err := originalTrie.TrieRoot()
require.NoError(t, err)
if root == newRoot {
t.Errorf("Wanted roots to be different, but they are the same: %#x", root)
require.Equal(t, originalRoot, rootAfter, "original should be unchanged after copy mutation")
// The mutated copy should have a different root.
if mutatedRoot == originalRoot {
t.Errorf("Wanted roots to be different, but they are the same: %#x", mutatedRoot)
}
}
func TestFieldTrie_LazyCopyOriginalMutationDoesNotAffectCopy(t *testing.T) {
// After LazyCopy, mutating the ORIGINAL must not affect the COPY.
newState, _ := util.DeterministicGenesisState(t, 32)
mixes := newState.RandaoMixes()
randaoMixes := make([][32]byte, len(mixes))
for i, r := range mixes {
randaoMixes[i] = [32]byte(r)
}
originalTrie, err := NewFieldTrie(types.RandaoMixes, types.BasicArray, customtypes.RandaoMixes(randaoMixes), uint64(params.BeaconConfig().EpochsPerHistoricalVector))
require.NoError(t, err)
originalRoot, err := originalTrie.TrieRoot()
require.NoError(t, err)
copiedTrie := originalTrie.CopyTrie()
// Verify the copy initially has the same root.
copyRoot, err := copiedTrie.TrieRoot()
require.NoError(t, err)
require.Equal(t, originalRoot, copyRoot, "copy should initially have same root")
// Mutate the ORIGINAL (the reverse of TestFieldTrie_CopyTrieImmutable).
changedIdx := []uint64{2, 29}
changedVals := [][32]byte{{'A', 'B'}, {'C', 'D'}}
require.NoError(t, newState.UpdateRandaoMixesAtIndex(changedIdx[0], changedVals[0]))
require.NoError(t, newState.UpdateRandaoMixesAtIndex(changedIdx[1], changedVals[1]))
mixes = newState.RandaoMixes()
randaoMixes = make([][32]byte, len(mixes))
for i, r := range mixes {
randaoMixes[i] = [32]byte(r)
}
mutatedRoot, err := originalTrie.RecomputeTrie(changedIdx, customtypes.RandaoMixes(randaoMixes))
require.NoError(t, err)
// The copy should be unchanged.
rootAfter, err := copiedTrie.TrieRoot()
require.NoError(t, err)
require.Equal(t, copyRoot, rootAfter, "copy should be unchanged after original mutation")
// The mutated original should have a different root.
if mutatedRoot == copyRoot {
t.Errorf("Wanted roots to be different, but they are the same: %#x", mutatedRoot)
}
}
@@ -168,25 +227,6 @@ func TestFieldTrie_CopyAndTransferEmpty(t *testing.T) {
require.NoError(t, err)
require.DeepEqual(t, trie, trie.CopyTrie())
require.DeepEqual(t, trie, trie.TransferTrie())
}
func TestFieldTrie_TransferTrie(t *testing.T) {
newState, _ := util.DeterministicGenesisState(t, 32)
maxLength := (params.BeaconConfig().ValidatorRegistryLimit*8 + 31) / 32
trie, err := NewFieldTrie(types.Balances, types.CompressedArray, newState.Balances(), maxLength)
require.NoError(t, err)
oldRoot, err := trie.TrieRoot()
require.NoError(t, err)
newTrie := trie.TransferTrie()
root, err := trie.TrieRoot()
require.ErrorIs(t, err, ErrEmptyFieldTrie)
require.Equal(t, root, [32]byte{})
require.NotNil(t, newTrie)
newRoot, err := newTrie.TrieRoot()
require.NoError(t, err)
require.DeepEqual(t, oldRoot, newRoot)
}
func FuzzFieldTrie(f *testing.F) {

View File

@@ -36,13 +36,13 @@ func Test_handleEth1DataSlice_OutOfRange(t *testing.T) {
func Test_handleValidatorSlice_OutOfRange(t *testing.T) {
vals := make([]*ethpb.Validator, 1)
indices := []uint64{3}
_, err := handleValidatorMVSlice(mvslice.BuildEmptyCompositeSlice[*ethpb.Validator](vals), indices, false)
_, err := handleValidatorMVSlice(mvslice.BuildEmptyCompositeSlice(vals), indices, false)
assert.ErrorContains(t, "index 3 greater than number of validators 1", err)
}
func TestBalancesSlice_CorrectRoots_All(t *testing.T) {
balances := []uint64{5, 2929, 34, 1291, 354305}
roots, err := handleBalanceMVSlice(mvslice.BuildEmptyCompositeSlice[uint64](balances), []uint64{}, true)
roots, err := handleBalanceMVSlice(mvslice.BuildEmptyCompositeSlice(balances), []uint64{}, true)
assert.NoError(t, err)
var root1 [32]byte
@@ -59,7 +59,7 @@ func TestBalancesSlice_CorrectRoots_All(t *testing.T) {
func TestBalancesSlice_CorrectRoots_Some(t *testing.T) {
balances := []uint64{5, 2929, 34, 1291, 354305}
roots, err := handleBalanceMVSlice(mvslice.BuildEmptyCompositeSlice[uint64](balances), []uint64{2, 3}, false)
roots, err := handleBalanceMVSlice(mvslice.BuildEmptyCompositeSlice(balances), []uint64{2, 3}, false)
assert.NoError(t, err)
var root1 [32]byte

View File

@@ -25,6 +25,7 @@ go_library(
"getters_withdrawal.go",
"gloas.go",
"hasher.go",
"merkle_layers.go",
"multi_value_slices.go",
"proofs.go",
"readonly_validator.go",

View File

@@ -85,7 +85,7 @@ type BeaconState struct {
stateFieldLeaves map[types.FieldIndex]*fieldtrie.FieldTrie
rebuildTrie map[types.FieldIndex]bool
valMapHandler *stateutil.ValidatorMapHandler
merkleLayers [][][]byte
merkle *sharedMerkleLayers
sharedFieldReferences map[types.FieldIndex]*stateutil.Reference
}

View File

@@ -0,0 +1,66 @@
package state_native
import (
"sync"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state/stateutil"
)
// sharedMerkleLayers wraps the beacon state's top-level Merkle tree layers with
// reference counting so that Copy can share them instead of deep-copying.
// All access is protected by the owning BeaconState's lock. This struct does
// not carry its own mutex.
type sharedMerkleLayers struct {
layers [][][]byte
ref *stateutil.Reference
mu sync.Mutex
}
// newSharedMerkleLayers wraps existing layers in a ref-counted container.
func newSharedMerkleLayers(layers [][][]byte) *sharedMerkleLayers {
return &sharedMerkleLayers{
layers: layers,
ref: stateutil.NewRef(1),
}
}
// copy increments the reference count and returns the same pointer, making
// BeaconState.Copy O(1) for this field. The caller must call ensureUnique
// before mutating the layers.
func (s *sharedMerkleLayers) copy() *sharedMerkleLayers {
s.ref.AddRef()
return s
}
// ensureUnique deep-copies the layers if this instance is shared (refs > 1)
// and returns the (possibly new) sharedMerkleLayers to use. The caller must
// replace its field with the returned value:
//
// b.merkle = b.merkle.ensureUnique()
func (s *sharedMerkleLayers) ensureUnique() *sharedMerkleLayers {
s.mu.Lock()
defer s.mu.Unlock()
if s.ref.Refs() == 1 {
return s
}
// Shared. Deep-copy and detach.
s.ref.MinusRef()
newLayers := make([][][]byte, len(s.layers))
for i, layer := range s.layers {
newLayers[i] = make([][]byte, len(layer))
for j, content := range layer {
newLayers[i][j] = make([]byte, len(content))
copy(newLayers[i][j], content)
}
}
return newSharedMerkleLayers(newLayers)
}
// release decrements the reference count. Called during finalizer cleanup.
func (s *sharedMerkleLayers) release() {
s.ref.MinusRef()
}

View File

@@ -56,7 +56,7 @@ func (b *BeaconState) CurrentSyncCommitteeProof(ctx context.Context) ([][]byte,
if err := b.recomputeDirtyFields(ctx); err != nil {
return nil, err
}
return trie.ProofFromMerkleLayers(b.merkleLayers, types.CurrentSyncCommittee.RealPosition()), nil
return trie.ProofFromMerkleLayers(b.merkle.layers, types.CurrentSyncCommittee.RealPosition()), nil
}
// NextSyncCommitteeProof from the state's Merkle trie representation.
@@ -74,7 +74,7 @@ func (b *BeaconState) NextSyncCommitteeProof(ctx context.Context) ([][]byte, err
if err := b.recomputeDirtyFields(ctx); err != nil {
return nil, err
}
return trie.ProofFromMerkleLayers(b.merkleLayers, types.NextSyncCommittee.RealPosition()), nil
return trie.ProofFromMerkleLayers(b.merkle.layers, types.NextSyncCommittee.RealPosition()), nil
}
// FinalizedRootProof crafts a Merkle proof for the finalized root
@@ -102,7 +102,7 @@ func (b *BeaconState) FinalizedRootProof(ctx context.Context) ([][]byte, error)
epochRoot := bytesutil.ToBytes32(epochBuf)
proof := make([][]byte, 0)
proof = append(proof, epochRoot[:])
branch := trie.ProofFromMerkleLayers(b.merkleLayers, types.FinalizedCheckpoint.RealPosition())
branch := trie.ProofFromMerkleLayers(b.merkle.layers, types.FinalizedCheckpoint.RealPosition())
proof = append(proof, branch...)
return proof, nil
}

View File

@@ -160,13 +160,13 @@ func (b *BeaconState) AppendHistoricalSummaries(summary *ethpb.HistoricalSummary
// hold the lock before calling this method.
func (b *BeaconState) recomputeRoot(idx int) {
hashFunc := hash.CustomSHA256Hasher()
layers := b.merkleLayers
layers := b.merkle.layers
// The merkle tree structure looks as follows:
// [[r1, r2, r3, r4], [parent1, parent2], [root]]
// Using information about the index which changed, idx, we recompute
// only its branch up the tree.
currentIndex := idx
root := b.merkleLayers[0][idx]
root := layers[0][idx]
for i := 0; i < len(layers)-1; i++ {
isLeft := currentIndex%2 == 0
neighborIdx := currentIndex ^ 1
@@ -187,7 +187,6 @@ func (b *BeaconState) recomputeRoot(idx int) {
layers[i+1][parentIdx] = root
currentIndex = parentIdx
}
b.merkleLayers = layers
}
func (b *BeaconState) markFieldAsDirty(field types.FieldIndex) {

View File

@@ -307,7 +307,7 @@ func TestBeaconState_AppendBalanceWithTrie(t *testing.T) {
}
_, err = st.HashTreeRoot(t.Context())
assert.NoError(t, err)
newRt := bytesutil.ToBytes32(st.merkleLayers[0][types.Balances])
newRt := bytesutil.ToBytes32(st.merkle.layers[0][types.Balances])
wantedRt, err := stateutil.Uint64ListRootWithRegistryLimit(st.Balances())
assert.NoError(t, err)
assert.Equal(t, wantedRt, newRt, "state roots are unequal")

View File

@@ -1024,18 +1024,12 @@ func (b *BeaconState) Copy() state.BeaconState {
}
}
if b.merkleLayers != nil {
dst.merkleLayers = make([][][]byte, len(b.merkleLayers))
for i, layer := range b.merkleLayers {
dst.merkleLayers[i] = make([][]byte, len(layer))
for j, content := range layer {
dst.merkleLayers[i][j] = make([]byte, len(content))
copy(dst.merkleLayers[i][j], content)
}
}
if b.merkle != nil {
dst.merkle = b.merkle.copy()
}
state.Count.Inc()
// Finalizer runs when dst is being destroyed in garbage collection.
runtime.SetFinalizer(dst, finalizerCleanup)
return dst
@@ -1055,14 +1049,14 @@ func (b *BeaconState) HashTreeRoot(ctx context.Context) ([32]byte, error) {
if err := b.recomputeDirtyFields(ctx); err != nil {
return [32]byte{}, err
}
return bytesutil.ToBytes32(b.merkleLayers[len(b.merkleLayers)-1][0]), nil
return bytesutil.ToBytes32(b.merkle.layers[len(b.merkle.layers)-1][0]), nil
}
// Initializes the Merkle layers for the beacon state if they are empty.
//
// WARNING: Caller must acquire the mutex before using.
func (b *BeaconState) initializeMerkleLayers(ctx context.Context) error {
if len(b.merkleLayers) > 0 {
if b.merkle != nil && len(b.merkle.layers) > 0 {
return nil
}
fieldRoots, err := ComputeFieldRootsWithHasher(ctx, b)
@@ -1070,7 +1064,7 @@ func (b *BeaconState) initializeMerkleLayers(ctx context.Context) error {
return err
}
layers := stateutil.Merkleize(fieldRoots)
b.merkleLayers = layers
b.merkle = newSharedMerkleLayers(layers)
switch b.version {
case version.Phase0:
b.dirtyFields = make(map[types.FieldIndex]bool, params.BeaconConfig().BeaconStateFieldCount)
@@ -1099,13 +1093,17 @@ func (b *BeaconState) initializeMerkleLayers(ctx context.Context) error {
//
// WARNING: Caller must acquire the mutex before using.
func (b *BeaconState) recomputeDirtyFields(ctx context.Context) error {
if len(b.dirtyFields) > 0 {
b.merkle = b.merkle.ensureUnique()
}
for field := range b.dirtyFields {
root, err := b.rootSelector(ctx, field)
if err != nil {
return err
}
idx := field.RealPosition()
b.merkleLayers[0][idx] = root[:]
b.merkle.layers[0][idx] = root[:]
b.recomputeRoot(idx)
delete(b.dirtyFields, field)
}
@@ -1462,6 +1460,9 @@ func finalizerCleanup(b *BeaconState) {
if b.validatorsMultiValue != nil {
b.validatorsMultiValue.Detach(b)
}
if b.merkle != nil {
b.merkle.release()
}
state.Count.Sub(1)
}

View File

@@ -0,0 +1,3 @@
### Added
- Implement finalization-based eviction for `CheckpointStateCache`.