Skip to content
Open
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
58 changes: 49 additions & 9 deletions execution/commitment/prefix_trie.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ import (
)

const prefixSlabSize = 16384
const prefixExtChunkSize = 64 * 1024

// prefixNode is a path-compressed prefix-trie node keyed on nibbles (each ext byte is one nibble 0x00..0x0F).
// children is dense: len == popcount(bitmap). subtreeCount is the number of distinct keys in the
// subtree; re-inserting an existing key merges its update without bumping it.
type prefixNode struct {
// ext is arena-backed: it stays valid only until the owning trie's Reset, which
// recycles the chunk in place. A reader that must outlive the batch copies it.
ext []byte
children []*prefixNode
plainKey []byte // set only where a key terminates
Expand All @@ -41,15 +44,20 @@ type prefixSlab struct {
nodes [prefixSlabSize]prefixNode
}

// prefixArena bump-allocates prefixNodes from a list of slabs.
// prefixArena bump-allocates prefixNodes from a list of slabs and leaf extensions from a list of byte chunks.
type prefixArena struct {
slabs []*prefixSlab
slabIdx int
nextIdx int
slabs []*prefixSlab
slabIdx int
nextIdx int
extChunks [][]byte
extChunkIdx int

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extChunkIdx is derivable state: it is always len(a.extChunks)-1. The index only ever advances together with an append to extChunks, and resetArena puts both back to 0 / len 1. So the if a.extChunkIdx >= len(a.extChunks) branch in allocExt is always taken, and the re-fetch after it always yields the freshly appended chunk.

Dropping the field and reading a.extChunks[len(a.extChunks)-1] removes one invariant a reader has to verify by hand. It also matters for the oversize case: the "growing in place is harmless" reasoning holds only while the newly selected chunk is guaranteed empty, which is precisely what this lockstep gives you.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied, and I think this one stops holding once the chunk-retention comment is addressed — the two interact.

extChunkIdx == len(extChunks)-1 was true only because resetArena dropped every chunk but the first, so the arena could never be sitting on a non-last chunk. Now that reset retains the grown chunks truncated, the next batch starts at chunk 0 with several empty chunks above it, and the index is precisely what lets it refill them in order instead of appending past them. Deriving it would make every reset re-grow the tail.

Your underlying point still lands though: the "growing in place is harmless" reasoning did depend on the lockstep. That is no longer what guarantees it — the explicit oversize guard is, so no chunk is ever grown under live sub-slices regardless of which one is current.

}

func newPrefixArena() *prefixArena {
return &prefixArena{slabs: []*prefixSlab{new(prefixSlab)}}
return &prefixArena{
slabs: []*prefixSlab{new(prefixSlab)},
extChunks: [][]byte{make([]byte, 0, prefixExtChunkSize)},
}
}

func (a *prefixArena) allocNode() *prefixNode {
Expand All @@ -66,7 +74,34 @@ func (a *prefixArena) allocNode() *prefixNode {
return n
}

// resetArena clears touched nodes for reuse, keeping the first slab and releasing the rest.
// allocExt copies b into the current chunk, swapping in a fresh chunk instead of growing this one
Comment thread
awskii marked this conversation as resolved.
// so sub-slices already handed out keep their backing array. An extension larger than a chunk gets
// its own allocation rather than forcing a chunk to grow under live sub-slices.
func (a *prefixArena) allocExt(b []byte) []byte {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the fourth chunked byte arena in this package, all with the same shape -- 64KB chunks, "the returned sub-slice must stay valid until reset":

  • plainKeyArena.intern -- parallel_update.go
  • keyArena.copy -- streaming_commitment.go
  • Updates.arenaAlloc / arenaEnsureCap -- commitment.go
  • prefixArena.allocExt -- here

They differ only in the overflow policy and in whether the returned slice is capped (arenaAlloc returns arena[off:needed], without the three-index form, so its callers do not get the append-safety this PR adds here). One shared byteArena type with a single overflow policy would remove three near-copies and give every caller the capped return. The new constant is also spelled 65536 while the other three use 64 * 1024.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on all four counts, and the constant is now 64 * 1024 to match its siblings.

Not unifying them in this PR: keyArena and plainKeyArena are both moving under the streaming-commitment removal (#23191), which relocates keyArena into streaming_deep_fold.go, and Updates.arenaAlloc has the different contract you point out — uncapped return, ring of arenas indexed by generation. Folding all four together across those in-flight moves would make both diffs hard to review. Worth a follow-up once #23191 and #23183 land, and the capped return is the part actually worth propagating — arenaAlloc's callers not getting append-safety is a live sharp edge, not just duplication.

if len(b) == 0 {
return nil
}
if len(b) > prefixExtChunkSize {
own := make([]byte, len(b))
copy(own, b)
return own
}
chunk := a.extChunks[a.extChunkIdx]
if cap(chunk)-len(chunk) < len(b) {
Comment thread
awskii marked this conversation as resolved.
a.extChunkIdx++
if a.extChunkIdx >= len(a.extChunks) {
a.extChunks = append(a.extChunks, make([]byte, 0, prefixExtChunkSize))
}
chunk = a.extChunks[a.extChunkIdx]
}
off := len(chunk)
chunk = append(chunk, b...)
a.extChunks[a.extChunkIdx] = chunk
return chunk[off:len(chunk):len(chunk)]
}

// resetArena clears touched nodes for reuse, keeping the first slab and releasing the rest, and
// truncates the extension chunks in place so the next batch refills them without reallocating.
func (a *prefixArena) resetArena() {
for i := 0; i <= a.slabIdx && i < len(a.slabs); i++ {
limit := prefixSlabSize
Expand All @@ -80,6 +115,11 @@ func (a *prefixArena) resetArena() {
a.slabs = a.slabs[:1]
a.slabIdx = 0
a.nextIdx = 0

for i := range a.extChunks {
a.extChunks[i] = a.extChunks[i][:0]
}
a.extChunkIdx = 0
}

func (a *prefixArena) nodeCount() int {
Expand Down Expand Up @@ -149,7 +189,7 @@ func (t *prefixTrie) Insert(hashedKey, plainKey []byte, update *Update) (isNew b
node.plainKey = nil
node.update = nil

node.ext = oldExt[:m]
node.ext = oldExt[:m:m]

if m == len(remain) {
// Key ends inside the old extension: one child, no new sibling.
Expand All @@ -163,7 +203,7 @@ func (t *prefixTrie) Insert(hashedKey, plainKey []byte, update *Update) (isNew b

newLeaf := t.arena.allocNode()
newNib := remain[m]
newLeaf.ext = append([]byte(nil), remain[m+1:]...)
newLeaf.ext = t.arena.allocExt(remain[m+1:])
Comment thread
awskii marked this conversation as resolved.
newLeaf.subtreeCount = 1
newLeaf.plainKey = plainKey
newLeaf.update = update
Expand Down Expand Up @@ -204,7 +244,7 @@ func (t *prefixTrie) Insert(hashedKey, plainKey []byte, update *Update) (isNew b
idx, ok := childIndex(node, nib)
if !ok {
newLeaf := t.arena.allocNode()
newLeaf.ext = append([]byte(nil), hashedKey[keyOffset+1:]...)
newLeaf.ext = t.arena.allocExt(hashedKey[keyOffset+1:])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for the follow-up: the per-leaf extension was one of two heap allocations per new node. The other is the children slice -- []*prefixNode{oldChild} and []*prefixNode{oldChild, newLeaf} in the split branch, plus the append(node.children, nil) growth just below -- and merged := &Update{} on the duplicate-key path. That is consistent with the residual 1.326M allocs/op after this change. A children region in the same arena (or a fixed [16]*prefixNode block per node) would compound with this PR rather than overlap it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, and it matches what the profile shows — the residual is dominated by the children slices, with merged := &Update{} behind them.

Deliberately out of scope here: the children region changes node layout rather than just where bytes come from, so it wants its own before/after. A fixed [16]*prefixNode per node trades ~128 bytes per node against the current dense slice, which is the opposite trade for a trie whose nodes are mostly sparse — a children arena with the dense layout kept is the version I would measure first.

newLeaf.subtreeCount = 1
newLeaf.plainKey = plainKey
newLeaf.update = update
Expand Down
53 changes: 53 additions & 0 deletions execution/commitment/prefix_trie_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package commitment

import (
"fmt"
"testing"
)

// The Benchmark_Commitment_* family cannot measure the trie build: runParallelBench
// calls WrapKeyUpdates, which drives every Insert, outside the timed region. This
// times the build itself across Reset cycles, so the arena's per-batch reuse is
// what the numbers reflect.
func Benchmark_PrefixTrieBuildAcrossResets(b *testing.B) {
for _, keys := range []int{5_000, 20_000} {
b.Run(fmt.Sprintf("%dk-keys", keys/1000), func(b *testing.B) {
const keyLen = 64
corpus := make([][]byte, keys)
for i := range corpus {
k := make([]byte, keyLen)
v := i
for j := range keyLen {
k[j] = byte(v % 16)
v /= 3
}
corpus[i] = k
}
tr := newPrefixTrie()
b.ReportAllocs()
b.ResetTimer()
for range b.N {
for _, k := range corpus {
tr.Insert(k, k, nil)
}
tr.Reset()
}
})
}
}
114 changes: 114 additions & 0 deletions execution/commitment/prefix_trie_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,81 @@ func TestPrefixTrieArenaReuse(t *testing.T) {
assert.Equal(t, first, tr.arena.nodeCount())
}

func TestPrefixArenaAllocExt(t *testing.T) {
t.Run("capEqualsLen", func(t *testing.T) {
a := newPrefixArena()
ext := a.allocExt([]byte{1, 2, 3, 4, 5})
assert.Equal(t, []byte{1, 2, 3, 4, 5}, ext)
assert.Equal(t, len(ext), cap(ext), "allocExt must not hand out spare capacity")
})

t.Run("emptyInputReturnsNil", func(t *testing.T) {
a := newPrefixArena()
assert.Nil(t, a.allocExt(nil))
assert.Nil(t, a.allocExt([]byte{}))
})

t.Run("appendPastCapDoesNotAliasNextExtension", func(t *testing.T) {
a := newPrefixArena()
first := a.allocExt([]byte{1, 2, 3})
second := a.allocExt([]byte{4, 5, 6})

first = append(first, 0xFF, 0xFF, 0xFF, 0xFF)

assert.Equal(t, []byte{4, 5, 6}, second, "growing one extension past its cap must not corrupt the next")
assert.Equal(t, []byte{1, 2, 3, 0xFF, 0xFF, 0xFF, 0xFF}, first)
})
}

func TestPrefixTrieExtSurvivesChunkBoundary(t *testing.T) {
tr := newPrefixTrie()

// keys share nibbles [4:keyLen) so leaf extensions stay long; nibbles [0:4) alone already
// make every key distinct, which is what drives enough allocExt traffic to cross a chunk.
const keyLen = 32
const total = 2 * prefixExtChunkSize / keyLen
want := make(map[string]bool, total)
for i := range total {
k := make([]byte, keyLen)
v := i
for j := range 4 {
k[j] = byte(v % 16)
v /= 16
}
want[string(k)] = true
tr.Insert(k, nil, nil)
}
require.Greater(t, len(tr.arena.extChunks), 1, "test must actually cross a chunk boundary")
Comment thread
awskii marked this conversation as resolved.

got := make(map[string]bool, total)
for _, e := range collectWalk(tr) {
if len(e.prefix) == keyLen {
got[string(e.prefix)] = true
}
}
assert.Equal(t, want, got, "leaf extensions must reproduce their original key bytes after crossing a chunk boundary")
}

func TestPrefixTrieArenaReusesExtChunkBacking(t *testing.T) {
tr := newPrefixTrie()
tr.Insert(nibs(0x01, 0x02, 0x03, 0x04), nil, nil)

chunk := tr.arena.extChunks[0]
full := chunk[:cap(chunk)]

tr.Reset()

require.Len(t, tr.arena.extChunks, 1, "Reset must trim trailing chunks")
assert.Empty(t, tr.arena.extChunks[0], "Reset must truncate the reused chunk's length")
assert.Equal(t, cap(chunk), cap(tr.arena.extChunks[0]), "Reset must keep the chunk's capacity")

tr.Insert(nibs(0x05, 0x06, 0x07, 0x08), nil, nil)
require.Len(t, tr.root.children, 1)
newExt := tr.root.children[0].ext
assert.Equal(t, nibs(0x06, 0x07, 0x08), newExt)
assert.Equal(t, newExt, full[:len(newExt)], "post-reset extension must land in the same backing array as before Reset")
}

func TestPrefixTrieArenaSpansMultipleSlabs(t *testing.T) {
tr := newPrefixTrie()
// Allocate directly to cross the slab boundary; reaching it via inserts needs >prefixSlabSize keys.
Expand Down Expand Up @@ -431,3 +506,42 @@ func TestPrefixTrieInsertDuplicateMerges(t *testing.T) {
// Merge is copy-on-write: a concurrent fold snapshot may still hold the prior update pointer.
assert.Equal(t, BalanceUpdate, first.Flags, "merge must not mutate the previously stored update")
}

func TestPrefixArenaAllocExt_OversizeAndReuse(t *testing.T) {
t.Run("extension larger than a chunk gets its own backing", func(t *testing.T) {
a := newPrefixArena()
big := bytes.Repeat([]byte{0x7}, prefixExtChunkSize+1)
got := a.allocExt(big)
require.Equal(t, big, got)
require.Equal(t, len(got), cap(got))
require.Len(t, a.extChunks, 1, "an oversize extension must not consume a chunk")
require.Empty(t, a.extChunks[0], "the current chunk must be untouched")
})

t.Run("reset refills existing chunks instead of reallocating", func(t *testing.T) {
a := newPrefixArena()
block := make([]byte, prefixExtChunkSize/4)
for range 12 {
a.allocExt(block)
}
require.Greater(t, len(a.extChunks), 1, "test must cross a chunk boundary")
grown := len(a.extChunks)
backing := make([]*byte, grown)
for i := range a.extChunks {
backing[i] = &a.extChunks[i][:1][0]
}

a.resetArena()
require.Len(t, a.extChunks, grown, "reset must keep the grown chunks")
for i := range a.extChunks {
require.Empty(t, a.extChunks[i])
}
for range 12 {
a.allocExt(block)
}
require.Len(t, a.extChunks, grown, "refill must reuse chunks, not allocate new ones")
for i := range a.extChunks {
require.Same(t, backing[i], &a.extChunks[i][:1][0], "chunk backing array must be reused")
}
})
}
Loading