trie: swap out the pools in StackTrie with an arena allocator

Arena allocators are a perfect match for something like StackTrie
where allocations happens in stack-like "frames" and can then be
deallocation at the end of the "allocation frame"

We can construct the "allocation frames" every time we create a new
extension/branch node and decontstruct the frame by deallocating everything
allocated on the arena during that frame.

This saves us from dealing with individual allocations/deallocations
This commit is contained in:
Ömer Faruk IRMAK 2025-07-16 15:03:29 +03:00
parent 0dacfef8ac
commit 773b0b290a
4 changed files with 231 additions and 103 deletions

61
common/arena.go Normal file
View file

@ -0,0 +1,61 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library 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.
//
// The go-ethereum library 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 the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
package common
// Arena is an allocation primitive that allows individual allocations
// out of a page of items and bulk de-allocations of last N allocations.
// The most common way of using an Arena is to pair it up with a sync.Pool
// of pages.
type Arena[T any] struct {
used uint32
pages [][]T
PageSize uint32
NewPage func() any
ReleasePage func(any)
}
// Alloc returns the next free item on the arena
// Allocates a new page if needed
func (a *Arena[T]) Alloc() *T {
pageIndex := a.used / a.PageSize
pageOffset := a.used % a.PageSize
if pageOffset == 0 {
a.pages = append(a.pages, a.NewPage().([]T))
}
a.used++
return &a.pages[pageIndex][pageOffset]
}
// Used returns the number of items that live on this arena
func (a *Arena[T]) Used() uint32 {
return a.used
}
// Reset rollsback the active set of live elements to the given number
func (a *Arena[T]) Reset(to uint32) {
a.used = to
}
// Release releases all the pages that the arena currently owns
func (a *Arena[T]) Release() {
for _, page := range a.pages {
a.ReleasePage(page)
}
a.pages = nil
a.used = 0
}

View file

@ -1,64 +0,0 @@
// Copyright 2024 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library 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.
//
// The go-ethereum library 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 the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
package trie
// bytesPool is a pool for byte slices. It is safe for concurrent use.
type bytesPool struct {
c chan []byte
w int
}
// newBytesPool creates a new bytesPool. The sliceCap sets the capacity of
// newly allocated slices, and the nitems determines how many items the pool
// will hold, at maximum.
func newBytesPool(sliceCap, nitems int) *bytesPool {
return &bytesPool{
c: make(chan []byte, nitems),
w: sliceCap,
}
}
// Get returns a slice. Safe for concurrent use.
func (bp *bytesPool) Get() []byte {
select {
case b := <-bp.c:
return b
default:
return make([]byte, 0, bp.w)
}
}
// GetWithSize returns a slice with specified byte slice size.
func (bp *bytesPool) GetWithSize(s int) []byte {
b := bp.Get()
if cap(b) < s {
return make([]byte, s)
}
return b[:s]
}
// Put returns a slice to the pool. Safe for concurrent use. This method
// will ignore slices that are too small or too large (>3x the cap)
func (bp *bytesPool) Put(b []byte) {
if c := cap(b); c < bp.w || c > 3*bp.w {
return
}
select {
case bp.c <- b:
default:
}
}

View file

@ -20,15 +20,31 @@ import (
"bytes" "bytes"
"errors" "errors"
"sync" "sync"
"unsafe"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/metrics"
) )
var stNodeAllocationMeter = metrics.NewRegisteredMeter("stacktrie/allocation/node", nil)
var byteAllocationMeter = metrics.NewRegisteredMeter("stacktrie/allocation/byte", nil)
var ( var (
stPool = sync.Pool{New: func() any { return new(stNode) }} stPageSize = 1024
bPool = newBytesPool(32, 100) stPagePool = sync.Pool{
_ = types.TrieHasher((*StackTrie)(nil)) New: func() any {
stNodeAllocationMeter.Mark(int64(unsafe.Sizeof(stNode{})) * int64(stPageSize))
return make([]stNode, stPageSize)
},
}
bytePagePool = sync.Pool{
New: func() any {
byteAllocationMeter.Mark(int64(unsafe.Sizeof([32]byte{})) * int64(stPageSize))
return make([][32]byte, stPageSize)
},
}
_ = types.TrieHasher((*StackTrie)(nil))
) )
// OnTrieNode is a callback method invoked when a trie node is committed // OnTrieNode is a callback method invoked when a trie node is committed
@ -40,6 +56,14 @@ var (
// it after the call ends. // it after the call ends.
type OnTrieNode func(path []byte, hash common.Hash, blob []byte) type OnTrieNode func(path []byte, hash common.Hash, blob []byte)
// allocationFrame keeps track of the position of the allocators in StackTrie
// at the time of a new branch/ext node being created. At the point where this
// node is hashed, allocators can be reset to the positions stored in the allocation
// frame
type allocationFrame struct {
node, bytes uint32
}
// StackTrie is a trie implementation that expects keys to be inserted // StackTrie is a trie implementation that expects keys to be inserted
// in order. Once it determines that a subtree will no longer be inserted // in order. Once it determines that a subtree will no longer be inserted
// into, it will hash it and free up the memory it uses. // into, it will hash it and free up the memory it uses.
@ -48,20 +72,47 @@ type StackTrie struct {
h *hasher h *hasher
last []byte last []byte
onTrieNode OnTrieNode onTrieNode OnTrieNode
kBuf []byte // buf space used for hex-key during insertions
pBuf []byte // buf space used for path during insertions nodeAllocator common.Arena[stNode]
byteAllocator common.Arena[[32]byte]
allocationStackFrames []allocationFrame
kBuf []byte // buf space used for hex-key during insertions
pBuf []byte // buf space used for path during insertions
tmpNode *stNode // used as a temporary ext node when needed
} }
// NewStackTrie allocates and initializes an empty trie. The committed nodes // NewStackTrie allocates and initializes an empty trie. The committed nodes
// will be discarded immediately if no callback is configured. // will be discarded immediately if no callback is configured.
func NewStackTrie(onTrieNode OnTrieNode) *StackTrie { func NewStackTrie(onTrieNode OnTrieNode) *StackTrie {
return &StackTrie{ t := StackTrie{
root: stPool.Get().(*stNode), h: newHasher(false),
h: newHasher(false), onTrieNode: onTrieNode,
onTrieNode: onTrieNode, kBuf: make([]byte, 64),
kBuf: make([]byte, 64), pBuf: make([]byte, 64),
pBuf: make([]byte, 64), nodeAllocator: common.Arena[stNode]{NewPage: stPagePool.Get, PageSize: uint32(stPageSize), ReleasePage: stPagePool.Put},
byteAllocator: common.Arena[[32]byte]{NewPage: bytePagePool.Get, PageSize: uint32(stPageSize), ReleasePage: bytePagePool.Put},
} }
t.root = t.nodeAllocator.Alloc().reset()
t.tmpNode = t.nodeAllocator.Alloc().reset()
return &t
}
// creates a new allocation frame by saving positions of internal allocators
func (t *StackTrie) pushAllocationFrame() {
t.allocationStackFrames = append(t.allocationStackFrames, allocationFrame{
node: t.nodeAllocator.Used(),
bytes: t.byteAllocator.Used(),
})
}
// pops a saved allocation frame and rollsback allocators to the state they were
// at the beginning of the frame
func (t *StackTrie) popAllocationFrame() {
allocationFrame := t.allocationStackFrames[len(t.allocationStackFrames)-1]
t.nodeAllocator.Reset(allocationFrame.node)
t.byteAllocator.Reset(allocationFrame.bytes)
t.allocationStackFrames = t.allocationStackFrames[:len(t.allocationStackFrames)-1]
} }
func (t *StackTrie) grow(key []byte) { func (t *StackTrie) grow(key []byte) {
@ -94,7 +145,10 @@ func (t *StackTrie) Update(key, value []byte) error {
// Reset resets the stack trie object to empty state. // Reset resets the stack trie object to empty state.
func (t *StackTrie) Reset() { func (t *StackTrie) Reset() {
t.root = stPool.Get().(*stNode) t.nodeAllocator.Reset(0)
t.byteAllocator.Reset(0)
t.root = t.nodeAllocator.Alloc().reset()
t.tmpNode = t.nodeAllocator.Alloc().reset()
t.last = nil t.last = nil
} }
@ -116,18 +170,17 @@ type stNode struct {
// newLeaf constructs a leaf node with provided node key and value. The key // newLeaf constructs a leaf node with provided node key and value. The key
// will be deep-copied in the function and safe to modify afterwards, but // will be deep-copied in the function and safe to modify afterwards, but
// value is not. // value is not.
func newLeaf(key, val []byte) *stNode { func (t *StackTrie) newLeaf(key, val []byte) *stNode {
st := stPool.Get().(*stNode) st := t.nodeAllocator.Alloc().reset()
st.typ = leafNode st.typ = leafNode
st.key = append(st.key, key...) st.key = append(st.key, key...)
st.val = val st.val = val
return st return st
} }
// newExt constructs an extension node with provided node key and child. The // makeExt constructs an extension node with provided node key and child. The
// key will be deep-copied in the function and safe to modify afterwards. // key will be deep-copied in the function and safe to modify afterwards.
func newExt(key []byte, child *stNode) *stNode { func makeExt(st *stNode, key []byte, child *stNode) *stNode {
st := stPool.Get().(*stNode)
st.typ = extNode st.typ = extNode
st.key = append(st.key, key...) st.key = append(st.key, key...)
st.children[0] = child st.children[0] = child
@ -144,12 +197,6 @@ const (
) )
func (n *stNode) reset() *stNode { func (n *stNode) reset() *stNode {
if n.typ == hashedNode {
// On hashnodes, we 'own' the val: it is guaranteed to be not held
// by external caller. Hence, when we arrive here, we can put it back
// into the pool
bPool.Put(n.val)
}
n.key = n.key[:0] n.key = n.key[:0]
n.val = nil n.val = nil
for i := range n.children { for i := range n.children {
@ -194,7 +241,7 @@ func (t *StackTrie) insert(st *stNode, key, value []byte, path []byte) {
// Add new child // Add new child
if st.children[idx] == nil { if st.children[idx] == nil {
st.children[idx] = newLeaf(key[1:], value) st.children[idx] = t.newLeaf(key[1:], value)
} else { } else {
t.insert(st.children[idx], key[1:], value, append(path, key[0])) t.insert(st.children[idx], key[1:], value, append(path, key[0]))
} }
@ -223,8 +270,14 @@ func (t *StackTrie) insert(st *stNode, key, value []byte, path []byte) {
// Break on the non-last byte, insert an intermediate // Break on the non-last byte, insert an intermediate
// extension. The path prefix of the newly-inserted // extension. The path prefix of the newly-inserted
// extension should also contain the different byte. // extension should also contain the different byte.
n = newExt(st.key[diffidx+1:], st.children[0]) e := makeExt(t.tmpNode.reset(), st.key[diffidx+1:], st.children[0]) // build a temporary extension node to hash
t.hash(n, append(path, st.key[:diffidx+1]...)) t.hash(e, append(path, st.key[:diffidx+1]...)) // frame belonging to st gets popped here
// allocate a new node to hold the hashed e
n = t.nodeAllocator.Alloc().reset()
n.typ = hashedNode
n.val = e.val
t.pushAllocationFrame() // for the "new" st that might end up being a branch or a ext
} else { } else {
// Break on the last byte, no need to insert // Break on the last byte, no need to insert
// an extension node: reuse the current node. // an extension node: reuse the current node.
@ -245,12 +298,14 @@ func (t *StackTrie) insert(st *stNode, key, value []byte, path []byte) {
// the common prefix is at least one byte // the common prefix is at least one byte
// long, insert a new intermediate branch // long, insert a new intermediate branch
// node. // node.
st.children[0] = stPool.Get().(*stNode) st.children[0] = t.nodeAllocator.Alloc().reset()
st.children[0].typ = branchNode st.children[0].typ = branchNode
t.pushAllocationFrame() // for the new branch child
p = st.children[0] p = st.children[0]
} }
// Create a leaf for the inserted part // Create a leaf for the inserted part
o := newLeaf(key[diffidx+1:], value) o := t.newLeaf(key[diffidx+1:], value)
// Insert both child leaves where they belong: // Insert both child leaves where they belong:
origIdx := st.key[diffidx] origIdx := st.key[diffidx]
@ -282,11 +337,14 @@ func (t *StackTrie) insert(st *stNode, key, value []byte, path []byte) {
st.typ = branchNode st.typ = branchNode
p = st p = st
st.children[0] = nil st.children[0] = nil
t.pushAllocationFrame() // leafnode turning into a branch node
} else { } else {
// Convert current node into an ext, // Convert current node into an ext,
// and insert a child branch node. // and insert a child branch node.
st.typ = extNode st.typ = extNode
st.children[0] = stPool.Get().(*stNode) t.pushAllocationFrame() // leafnode turning into a ext node
st.children[0] = t.nodeAllocator.Alloc().reset()
t.pushAllocationFrame() // new branch node
st.children[0].typ = branchNode st.children[0].typ = branchNode
p = st.children[0] p = st.children[0]
} }
@ -295,11 +353,11 @@ func (t *StackTrie) insert(st *stNode, key, value []byte, path []byte) {
// value and another containing the new value. The child leaf // value and another containing the new value. The child leaf
// is hashed directly in order to free up some memory. // is hashed directly in order to free up some memory.
origIdx := st.key[diffidx] origIdx := st.key[diffidx]
p.children[origIdx] = newLeaf(st.key[diffidx+1:], st.val) p.children[origIdx] = t.newLeaf(st.key[diffidx+1:], st.val)
t.hash(p.children[origIdx], append(path, st.key[:diffidx+1]...)) t.hash(p.children[origIdx], append(path, st.key[:diffidx+1]...))
newIdx := key[diffidx] newIdx := key[diffidx]
p.children[newIdx] = newLeaf(key[diffidx+1:], value) p.children[newIdx] = t.newLeaf(key[diffidx+1:], value)
// Finally, cut off the key part that has been passed // Finally, cut off the key part that has been passed
// over to the children. // over to the children.
@ -359,9 +417,8 @@ func (t *StackTrie) hash(st *stNode, path []byte) {
continue continue
} }
st.children[i] = nil st.children[i] = nil
stPool.Put(child.reset()) // Release child back to pool.
} }
t.popAllocationFrame()
case extNode: case extNode:
// recursively hash and commit child as the first step // recursively hash and commit child as the first step
t.hash(st.children[0], append(path, st.key...)) t.hash(st.children[0], append(path, st.key...))
@ -373,10 +430,8 @@ func (t *StackTrie) hash(st *stNode, path []byte) {
} }
n.encode(t.h.encbuf) n.encode(t.h.encbuf)
blob = t.h.encodedBytes() blob = t.h.encodedBytes()
stPool.Put(st.children[0].reset()) // Release child back to pool.
st.children[0] = nil st.children[0] = nil
t.popAllocationFrame()
case leafNode: case leafNode:
st.key = append(st.key, byte(16)) st.key = append(st.key, byte(16))
n := leafNodeEncoder{ n := leafNodeEncoder{
@ -398,13 +453,13 @@ func (t *StackTrie) hash(st *stNode, path []byte) {
// Skip committing the non-root node if the size is smaller than 32 bytes // Skip committing the non-root node if the size is smaller than 32 bytes
// as tiny nodes are always embedded in their parent except root node. // as tiny nodes are always embedded in their parent except root node.
if len(blob) < 32 && len(path) > 0 { if len(blob) < 32 && len(path) > 0 {
st.val = bPool.GetWithSize(len(blob)) st.val = t.byteAllocator.Alloc()[:len(blob)]
copy(st.val, blob) copy(st.val, blob)
return return
} }
// Write the hash to the 'val'. We allocate a new val here to not mutate // Write the hash to the 'val'. We allocate a new val here to not mutate
// input values. // input values.
st.val = bPool.GetWithSize(32) st.val = t.byteAllocator.Alloc()[:32]
t.h.hashDataTo(st.val, blob) t.h.hashDataTo(st.val, blob)
// Invoke the callback it's provided. Notably, the path and blob slices are // Invoke the callback it's provided. Notably, the path and blob slices are
@ -422,5 +477,8 @@ func (t *StackTrie) hash(st *stNode, path []byte) {
func (t *StackTrie) Hash() common.Hash { func (t *StackTrie) Hash() common.Hash {
n := t.root n := t.root
t.hash(n, nil) t.hash(n, nil)
return common.BytesToHash(n.val) hash := common.BytesToHash(n.val)
t.byteAllocator.Release()
t.nodeAllocator.Release()
return hash
} }

View file

@ -26,6 +26,7 @@ import (
"github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestStackTrieInsertAndHash(t *testing.T) { func TestStackTrieInsertAndHash(t *testing.T) {
@ -444,3 +445,75 @@ func TestInsert100K(t *testing.T) {
t.Fatalf("hash wrong, have %x want %x", have, want) t.Fatalf("hash wrong, have %x want %x", have, want)
} }
} }
func TestAllocationFrame(t *testing.T) {
val := []byte{0}
t.Run("leaf turning into a branch", func(t *testing.T) {
s := NewStackTrie(nil)
require.NoError(t, s.Update([]byte{0x01, 0x00}, val))
assert.Equal(t, uint8(leafNode), s.root.typ)
assert.Equal(t, []byte{0, 0x1, 0, 0}, s.root.key)
require.NoError(t, s.Update([]byte{0x11, 0x00}, val))
assert.Equal(t, uint8(branchNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 1)
s.Hash()
assert.Len(t, s.allocationStackFrames, 0)
})
t.Run("leaf turning into an extension node", func(t *testing.T) {
s := NewStackTrie(nil)
require.NoError(t, s.Update([]byte{0x01, 0x00}, val))
assert.Equal(t, uint8(leafNode), s.root.typ)
assert.Equal(t, []byte{0, 0x1, 0, 0}, s.root.key)
require.NoError(t, s.Update([]byte{0x01, 0x10}, val))
assert.Equal(t, uint8(extNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 2)
s.Hash()
assert.Len(t, s.allocationStackFrames, 0)
})
t.Run("extension node creates an intermediate branch", func(t *testing.T) {
s := NewStackTrie(nil)
require.NoError(t, s.Update([]byte{0x01, 0x00}, val))
assert.Equal(t, uint8(leafNode), s.root.typ)
assert.Equal(t, []byte{0, 0x1, 0, 0}, s.root.key)
require.NoError(t, s.Update([]byte{0x01, 0x10}, val))
assert.Equal(t, uint8(extNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 2)
require.NoError(t, s.Update([]byte{0x02, 0x10}, val))
assert.Equal(t, uint8(extNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 2)
s.Hash()
assert.Len(t, s.allocationStackFrames, 0)
})
t.Run("extension node creates an intermediate extension node", func(t *testing.T) {
s := NewStackTrie(nil)
require.NoError(t, s.Update([]byte{0x01, 0x00}, val))
assert.Equal(t, uint8(leafNode), s.root.typ)
assert.Equal(t, []byte{0, 0x1, 0, 0}, s.root.key)
require.NoError(t, s.Update([]byte{0x01, 0x10}, val))
assert.Equal(t, uint8(extNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 2)
require.NoError(t, s.Update([]byte{0x11, 0x10}, val))
assert.Equal(t, uint8(branchNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 1)
s.Hash()
assert.Len(t, s.allocationStackFrames, 0)
})
t.Run("extension node creates an intermediate extension node (2)", func(t *testing.T) {
s := NewStackTrie(nil)
require.NoError(t, s.Update([]byte{0x00, 0x10}, val))
assert.Equal(t, uint8(leafNode), s.root.typ)
assert.Equal(t, []byte{0, 0, 1, 0}, s.root.key)
require.NoError(t, s.Update([]byte{0x00, 0x11}, val))
assert.Equal(t, uint8(extNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 2)
require.NoError(t, s.Update([]byte{0x01, 0x11}, val))
assert.Equal(t, uint8(extNode), s.root.typ)
assert.Len(t, s.allocationStackFrames, 2)
s.Hash()
assert.Len(t, s.allocationStackFrames, 0)
})
}