Skip to content
Merged
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
43 changes: 34 additions & 9 deletions execution/commitment/commitment.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"math/bits"
"reflect"
"slices"
"strings"
"sync"
Expand Down Expand Up @@ -1452,6 +1453,12 @@ type Updates struct {
arenas [arenaRingSize][]byte
curArena int
gen uint64

// addrCache reuses the nibblized keccak(addr) prefix across a run of storage
// keys sharing one address (whale storage). Enabled only when hasher is the
// nibblizing hasher whose key layout the reuse assumes (addrCacheReuse).
addrCache addrHashCache
addrCacheReuse bool
}

// arenaRingSize is how many byte arenas HashSort cycles; raising it only adds memory headroom, never affects correctness.
Expand Down Expand Up @@ -1499,6 +1506,22 @@ type keyHasher func(key []byte) []byte

func keyHasherNoop(key []byte) []byte { return key }

// hasherReusesAddrPrefix reports whether h is the nibblizing hasher whose key
// layout keyToHexNibbleHashCached assumes; only then may the address-prefix
// cache be used in place of h.
func hasherReusesAddrPrefix(h keyHasher) bool {
return reflect.ValueOf(h).Pointer() == reflect.ValueOf(KeyToHexNibbleHash).Pointer()
}

// hashKey nibblizes key, reusing the cached address prefix for a run of storage
// keys sharing one address when the configured hasher permits it.
func (t *Updates) hashKey(key []byte) []byte {
if t.addrCacheReuse {
return keyToHexNibbleHashCached(key, &t.addrCache)
}
return t.hasher(key)
}

// NewEmpty creates a fresh Updates matching the receiver. The streaming sink must
// carry over, or a buffer rotated mid-stream silently computes a stale root.
func (t *Updates) NewEmpty() *Updates {
Expand All @@ -1510,9 +1533,10 @@ func (t *Updates) NewEmpty() *Updates {

func NewUpdates(m Mode, tmpdir string, hasher keyHasher) *Updates {
t := &Updates{
hasher: hasher,
tmpdir: tmpdir,
mode: m,
hasher: hasher,
tmpdir: tmpdir,
mode: m,
addrCacheReuse: hasherReusesAddrPrefix(hasher),
}
switch t.mode {
case ModeDirect:
Expand Down Expand Up @@ -1606,7 +1630,7 @@ func (t *Updates) TouchPlainKey(key string, val []byte, fn func(c *KeyUpdate, va
} else {
pivot := &KeyUpdate{
plainKey: key,
hashedKey: t.hasher(common.ToBytesZeroCopy(key)),
hashedKey: t.hashKey(common.ToBytesZeroCopy(key)),
update: new(Update),
}
fn(pivot, val)
Expand All @@ -1616,7 +1640,7 @@ func (t *Updates) TouchPlainKey(key string, val []byte, fn func(c *KeyUpdate, va
case ModeDirect:
if _, ok := t.keys[key]; !ok {
keyBytes := common.ToBytesZeroCopy(key)
hashedKey := t.hasher(keyBytes)
hashedKey := t.hashKey(keyBytes)

err := t.etl.Collect(hashedKey, keyBytes)
if err != nil {
Expand All @@ -1627,7 +1651,7 @@ func (t *Updates) TouchPlainKey(key string, val []byte, fn func(c *KeyUpdate, va
case ModeParallel:
if _, ok := t.keys[key]; !ok {
keyBytes := common.ToBytesZeroCopy(key)
hashedKey := t.hasher(keyBytes)
hashedKey := t.hashKey(keyBytes)
ik := t.parallel.internKey(keyBytes)
t.parallel.Insert(hashedKey, ik, nil)
if t.streaming && t.streamer != nil {
Expand Down Expand Up @@ -1678,7 +1702,7 @@ func (t *Updates) TouchPlainKeyDirect(key string, update *Update) {
} else {
pivot := &KeyUpdate{
plainKey: key,
hashedKey: t.hasher(common.ToBytesZeroCopy(key)),
hashedKey: t.hashKey(common.ToBytesZeroCopy(key)),
update: new(Update),
}
*pivot.update = *update
Expand All @@ -1688,7 +1712,7 @@ func (t *Updates) TouchPlainKeyDirect(key string, update *Update) {
case ModeDirect:
if _, ok := t.keys[key]; !ok {
keyBytes := common.ToBytesZeroCopy(key)
hashedKey := t.hasher(keyBytes)
hashedKey := t.hashKey(keyBytes)

err := t.etl.Collect(hashedKey, keyBytes)
if err != nil {
Expand All @@ -1698,7 +1722,7 @@ func (t *Updates) TouchPlainKeyDirect(key string, update *Update) {
}
case ModeParallel:
keyBytes := common.ToBytesZeroCopy(key)
hashedKey := t.hasher(keyBytes)
hashedKey := t.hashKey(keyBytes)
// Carry the value so the fold uses it directly instead of re-reading ctx, which lags cc.state.
u := new(Update)
*u = *update
Expand Down Expand Up @@ -2025,6 +2049,7 @@ func (t *Updates) Reset() {
}
t.curArena = 0
t.gen = 0
t.addrCache.reset()
}

type KeyUpdate struct {
Expand Down
44 changes: 44 additions & 0 deletions execution/commitment/keys_nibbles.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,50 @@ func KeyToHexNibbleHash(key []byte) []byte {
return nibblized
}

// expandNibbles writes each byte of src as two nibbles (src[i] -> dst[2i], dst[2i+1]).
// src and dst must not overlap.
func expandNibbles(src, dst []byte) {
_ = dst[len(src)*2-1] // bounds-check elimination
for i, b := range src {
dst[i*2] = (b >> 4) & 0xf
dst[i*2+1] = b & 0xf
}
}

// addrHashCache memoizes the nibblized keccak(addr) prefix of the most recent
// storage key's address, so a run of slots under one address (whale storage)
// reuses the 64-nibble prefix instead of re-hashing the address. keccak(addr)
// is immutable, so a hit is always correct and a miss simply recomputes.
type addrHashCache struct {
addr [20]byte
nibs [64]byte
valid bool
}

func (c *addrHashCache) reset() { c.valid = false }

// keyToHexNibbleHashCached returns the same bytes as KeyToHexNibbleHash, reusing
// c's cached address prefix across consecutive storage keys that share an address.
func keyToHexNibbleHashCached(key []byte, c *addrHashCache) []byte {
if len(key) <= length.Addr { // account key: no reusable prefix
return KeyToHexNibbleHash(key)
}
nibblized := make([]byte, 128)
addr := [20]byte(key[:length.Addr])
Comment on lines +70 to +71
if c.valid && c.addr == addr {
copy(nibblized[:64], c.nibs[:])
} else {
h := keccak.Sum256(key[:length.Addr])
expandNibbles(h[:], nibblized[:64])
c.addr = addr
copy(c.nibs[:], nibblized[:64])
c.valid = true
}
h := keccak.Sum256(key[length.Addr:])
expandNibbles(h[:], nibblized[64:])
return nibblized
}

func KeyToNibblizedHash(key []byte) []byte {
nibblized := make([]byte, 64) // nibblized hash
hashed := nibblized[32:]
Expand Down
188 changes: 188 additions & 0 deletions execution/commitment/keys_nibbles_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package commitment

import (
"testing"

"github.com/erigontech/erigon/common/length"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestKeyToHexNibbleHashCached_MatchesUncached verifies the cached variant is
// byte-identical to KeyToHexNibbleHash regardless of key type or ordering — a
// cache hit and a cache miss must both reproduce the uncached result.
func TestKeyToHexNibbleHashCached_MatchesUncached(t *testing.T) {
t.Parallel()

t.Run("account_keys", func(t *testing.T) {
var c addrHashCache
for i := 0; i < 100; i++ {
addr := make([]byte, length.Addr)
addr[0] = byte(i)
addr[19] = byte(i * 7)
assert.Equal(t, KeyToHexNibbleHash(addr), keyToHexNibbleHashCached(addr, &c), "account key %d", i)
}
})

t.Run("storage_keys", func(t *testing.T) {
var c addrHashCache
for i := 0; i < 100; i++ {
key := make([]byte, 52)
key[0] = byte(i % 30)
key[19] = byte(i)
key[20] = byte(i)
key[51] = byte(i * 3)
assert.Equal(t, KeyToHexNibbleHash(key), keyToHexNibbleHashCached(key, &c), "storage key %d", i)
}
})

// Whale: one address, many slots — the reuse target.
t.Run("whale_storage", func(t *testing.T) {
var c addrHashCache
addr := make([]byte, length.Addr)
addr[0], addr[1], addr[19] = 0xDE, 0xAD, 0xBE
for slot := 0; slot < 1000; slot++ {
key := make([]byte, 52)
copy(key[:20], addr)
key[20] = byte(slot >> 8)
key[51] = byte(slot)
assert.Equal(t, KeyToHexNibbleHash(key), keyToHexNibbleHashCached(key, &c), "whale slot %d", slot)
}
})

// Account/storage interleaving forces cache misses and address changes;
// the cache must never leak a stale prefix across an address change.
t.Run("interleaved", func(t *testing.T) {
var c addrHashCache
for i := 0; i < 200; i++ {
addr := make([]byte, length.Addr)
addr[0] = byte(i % 4) // only 4 distinct addresses, non-consecutive
addr[19] = byte(i % 4)
assert.Equal(t, KeyToHexNibbleHash(addr), keyToHexNibbleHashCached(addr, &c), "acct %d", i)

key := make([]byte, 52)
copy(key[:20], addr)
key[20] = byte(i)
key[51] = byte(i)
assert.Equal(t, KeyToHexNibbleHash(key), keyToHexNibbleHashCached(key, &c), "storage %d", i)
}
})
}

// TestAddrHashCache_ReuseAndInvalidation pins the cache state transitions the
// reuse depends on: populated on first storage slot, retained across same-addr
// slots, replaced on an address change, cleared by reset.
func TestAddrHashCache_ReuseAndInvalidation(t *testing.T) {
t.Parallel()
var c addrHashCache
require.False(t, c.valid)

mkKey := func(addrByte, slot byte) []byte {
key := make([]byte, 52)
key[0] = addrByte
key[51] = slot
return key
}

keyToHexNibbleHashCached(mkKey(0xAA, 0), &c)
require.True(t, c.valid)
require.Equal(t, byte(0xAA), c.addr[0])
firstNibs := c.nibs

// Same address, different slot: prefix retained unchanged.
keyToHexNibbleHashCached(mkKey(0xAA, 1), &c)
require.Equal(t, firstNibs, c.nibs)

// Different address: prefix replaced.
keyToHexNibbleHashCached(mkKey(0xBB, 0), &c)
require.Equal(t, byte(0xBB), c.addr[0])
require.NotEqual(t, firstNibs, c.nibs)

// Account key does not touch the cache.
acctBefore := c.addr
keyToHexNibbleHashCached(make([]byte, length.Addr), &c)
require.Equal(t, acctBefore, c.addr)

c.reset()
require.False(t, c.valid)
}

// TestUpdatesHashKey_MatchesHasher verifies hashKey reproduces the configured
// hasher across every mode that hashes plain keys.
func TestUpdatesHashKey_MatchesHasher(t *testing.T) {
t.Parallel()
keys := [][]byte{
{0x01, 0x02},
make([]byte, length.Addr),
func() []byte { k := make([]byte, 52); k[0], k[51] = 0x11, 0x22; return k }(),
}
for _, mode := range []Mode{ModeDirect, ModeUpdate, ModeParallel} {
u := NewUpdates(mode, t.TempDir(), KeyToHexNibbleHash)
require.True(t, u.addrCacheReuse, "cache must be enabled for the nibblizing hasher")
for _, k := range keys {
assert.Equal(t, KeyToHexNibbleHash(k), u.hashKey(k), "mode=%d key=%x", mode, k)
}
}
}

func TestHasherReusesAddrPrefix(t *testing.T) {
t.Parallel()
assert.True(t, hasherReusesAddrPrefix(KeyToHexNibbleHash))
assert.False(t, hasherReusesAddrPrefix(keyHasherNoop))
}

func benchKeys(numAddr, slotsPer int) [][]byte {
keys := make([][]byte, 0, numAddr*slotsPer)
for a := 0; a < numAddr; a++ {
for s := 0; s < slotsPer; s++ {
k := make([]byte, 52)
k[0] = byte(a)
k[1] = byte(a >> 8)
k[19] = byte(a * 7)
k[20] = byte(s >> 8)
k[51] = byte(s)
keys = append(keys, k)
}
}
return keys
}

var benchWorkloads = []struct {
name string
numAddr int
slots int
}{
{"whale_1x1000", 1, 1000},
{"spread5_5x200", 5, 200},
{"spread100_100x10", 100, 10},
{"scatter1000_1000x1", 1000, 1},
}

func Benchmark_KeyNibbleHash_NoCache(b *testing.B) {
for _, w := range benchWorkloads {
keys := benchKeys(w.numAddr, w.slots)
b.Run(w.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, k := range keys {
_ = KeyToHexNibbleHash(k)
}
}
})
}
}

func Benchmark_KeyNibbleHash_Cached(b *testing.B) {
for _, w := range benchWorkloads {
keys := benchKeys(w.numAddr, w.slots)
b.Run(w.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var c addrHashCache
for _, k := range keys {
_ = keyToHexNibbleHashCached(k, &c)
}
}
})
}
}
Loading