-
Notifications
You must be signed in to change notification settings - Fork 1.5k
execution/commitment: arena-allocate prefixNode extensions #23188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e09d1d6
a537b72
fa2f888
b97a0d2
1b25f6f
87cc4aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| 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 { | ||
|
|
@@ -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 | ||
|
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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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":
They differ only in the overflow policy and in whether the returned slice is capped (
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on all four counts, and the constant is now Not unifying them in this PR: |
||
| 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) { | ||
|
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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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. | ||
|
|
@@ -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:]) | ||
|
awskii marked this conversation as resolved.
|
||
| newLeaf.subtreeCount = 1 | ||
| newLeaf.plainKey = plainKey | ||
| newLeaf.update = update | ||
|
|
@@ -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:]) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 --
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||
| newLeaf.subtreeCount = 1 | ||
| newLeaf.plainKey = plainKey | ||
| newLeaf.update = update | ||
|
|
||
| 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() | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extChunkIdxis derivable state: it is alwayslen(a.extChunks)-1. The index only ever advances together with anappendtoextChunks, andresetArenaputs both back to 0 / len 1. So theif a.extChunkIdx >= len(a.extChunks)branch inallocExtis 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.There was a problem hiding this comment.
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)-1was true only becauseresetArenadropped 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.