diff --git a/blockchain/aggregated_bloom_filter_cache.go b/blockchain/aggregated_bloom_filter_cache.go index e7f6c40131..d93c917efd 100644 --- a/blockchain/aggregated_bloom_filter_cache.go +++ b/blockchain/aggregated_bloom_filter_cache.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/utils/lru" "github.com/bits-and-blooms/bitset" - "github.com/ethereum/go-ethereum/common/lru" ) // NOTE(Ege): consider making it configurable @@ -27,7 +27,7 @@ type EventFiltersCacheKey struct { // for block ranges, supporting fallback loading and bulk insertion. // It is safe for concurrent use. type AggregatedBloomFilterCache struct { - cache lru.Cache[EventFiltersCacheKey, *core.AggregatedBloomFilter] + cache *lru.Cache[EventFiltersCacheKey, *core.AggregatedBloomFilter] fallbackFunc func(EventFiltersCacheKey) (core.AggregatedBloomFilter, error) } @@ -35,7 +35,10 @@ type AggregatedBloomFilterCache struct { // with the specified maximum size (number of ranges to cache). func NewAggregatedBloomCache(size int) AggregatedBloomFilterCache { return AggregatedBloomFilterCache{ - cache: *lru.NewCache[EventFiltersCacheKey, *core.AggregatedBloomFilter](size), + cache: lru.New[ + EventFiltersCacheKey, + *core.AggregatedBloomFilter, + ](size), } } diff --git a/go.mod b/go.mod index 90f777e562..ffb73e3cc2 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/ethereum/go-ethereum v1.17.3 github.com/fxamacker/cbor/v2 v2.9.2 github.com/go-playground/validator/v10 v10.30.2 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/libp2p/go-libp2p v0.48.0 github.com/libp2p/go-libp2p-kad-dht v0.39.1 github.com/libp2p/go-libp2p-pubsub v0.16.0 @@ -84,7 +85,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/rpc/v10/handlers.go b/rpc/v10/handlers.go index f1b3be80a6..593722062e 100644 --- a/rpc/v10/handlers.go +++ b/rpc/v10/handlers.go @@ -21,9 +21,9 @@ import ( "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" "github.com/NethermindEth/juno/utils/log" + "github.com/NethermindEth/juno/utils/lru" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common/lru" "github.com/sourcegraph/conc" ) @@ -78,6 +78,7 @@ func New( if err != nil { logger.Fatalf("Failed to parse ABI: %v", err) } + return &Handler{ bcReader: bcReader, syncReader: syncReader, @@ -95,7 +96,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: lru.NewCache[ + blockTraceCache: lru.New[ rpccore.TraceCacheKey, TraceBlockTransactionsResponse, ](rpccore.TraceCacheSize), diff --git a/rpc/v8/handlers.go b/rpc/v8/handlers.go index 145feb5c74..c231f6e220 100644 --- a/rpc/v8/handlers.go +++ b/rpc/v8/handlers.go @@ -21,9 +21,9 @@ import ( "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" "github.com/NethermindEth/juno/utils/log" + "github.com/NethermindEth/juno/utils/lru" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common/lru" "github.com/sourcegraph/conc" ) @@ -89,7 +89,10 @@ func New( preConfirmedFeed: feed.New[*pendingpkg.PreConfirmed](), l1Heads: feed.New[*core.L1Head](), - blockTraceCache: lru.NewCache[rpccore.TraceCacheKey, []TracedBlockTransaction](rpccore.TraceCacheSize), + blockTraceCache: lru.New[ + rpccore.TraceCacheKey, + []TracedBlockTransaction, + ](rpccore.TraceCacheSize), filterLimit: math.MaxUint, coreContractABI: contractABI, } diff --git a/rpc/v9/handlers.go b/rpc/v9/handlers.go index 3048f88880..d169ce40a3 100644 --- a/rpc/v9/handlers.go +++ b/rpc/v9/handlers.go @@ -21,9 +21,9 @@ import ( "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" "github.com/NethermindEth/juno/utils/log" + "github.com/NethermindEth/juno/utils/lru" "github.com/NethermindEth/juno/vm" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common/lru" "github.com/sourcegraph/conc" ) @@ -94,7 +94,7 @@ func New( l1Heads: feed.New[*core.L1Head](), preLatestFeed: feed.New[*pending.PreLatest](), - blockTraceCache: lru.NewCache[ + blockTraceCache: lru.New[ rpccore.TraceCacheKey, []TracedBlockTransaction, ](rpccore.TraceCacheSize), diff --git a/sync/pending_polling.go b/sync/pending_polling.go index 82b5659a95..29af7f31f6 100644 --- a/sync/pending_polling.go +++ b/sync/pending_polling.go @@ -10,7 +10,7 @@ import ( "github.com/NethermindEth/juno/core/felt" "github.com/NethermindEth/juno/core/pending" "github.com/NethermindEth/juno/db" - "github.com/ethereum/go-ethereum/common/lru" + "github.com/NethermindEth/juno/utils/lru" "go.uber.org/zap" ) @@ -130,7 +130,7 @@ func (s *Synchronizer) storeEmptyPreConfirmed( func (s *Synchronizer) handleTickerPreLatest( ctx context.Context, currentHead *core.Block, - seenByParent *lru.BasicLRU[felt.Felt, *pending.PreLatest], + seenByParent *lru.SimpleCache[felt.Felt, *pending.PreLatest], out chan<- *pending.PreLatest, ) bool { preLatest, err := s.dataSource.BlockPreLatest(ctx) @@ -168,7 +168,7 @@ func (s *Synchronizer) pollPreLatest(ctx context.Context, out chan<- *pending.Pr // Cache of pre-latest blocks keyed by the hash of their parent. // When we receive the head with this parent hash, we emit the cached pre-latest. - seenByParent := lru.NewBasicLRU[felt.Felt, *pending.PreLatest](preLatestCacheSize) + seenByParent := lru.NewSimple[felt.Felt, *pending.PreLatest](preLatestCacheSize) ticker := time.NewTicker(s.preLatestPollInterval) defer ticker.Stop() @@ -219,7 +219,7 @@ func (s *Synchronizer) pollPreLatest(ctx context.Context, out chan<- *pending.Pr deliveredForHead = s.handleTickerPreLatest( ctx, currentHead, - &seenByParent, + seenByParent, out, ) } diff --git a/utils/lru/lru.go b/utils/lru/lru.go new file mode 100644 index 0000000000..a91a3866c7 --- /dev/null +++ b/utils/lru/lru.go @@ -0,0 +1,51 @@ +package lru + +import ( + "fmt" + + hashilru "github.com/hashicorp/golang-lru/v2" + "github.com/hashicorp/golang-lru/v2/simplelru" +) + +// Cache is a thread-safe size-bounded LRU cache. +type Cache[K comparable, V any] struct { + c *hashilru.Cache[K, V] +} + +// New returns a new thread-safe LRU cache or panics if size <= 0. +// Use for caches sized by constants or validated config, where a zero size +// indicates a programmer error rather than a runtime condition. +func New[K comparable, V any](size int) *Cache[K, V] { + c, err := hashilru.New[K, V](size) + if err != nil { + panic(fmt.Errorf("lru: %w (size=%d)", err, size)) + } + return &Cache[K, V]{c: c} +} + +func (l *Cache[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } +func (l *Cache[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } +func (l *Cache[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } +func (l *Cache[K, V]) Purge() { l.c.Purge() } +func (l *Cache[K, V]) Len() int { return l.c.Len() } + +// SimpleCache is a non-thread-safe size-bounded LRU cache. +// Use when external synchronization is provided (e.g. single-goroutine ownership). +type SimpleCache[K comparable, V any] struct { + c *simplelru.LRU[K, V] +} + +// NewSimple returns a new non-thread-safe LRU cache or panics if size <= 0. +func NewSimple[K comparable, V any](size int) *SimpleCache[K, V] { + c, err := simplelru.NewLRU[K, V](size, nil) + if err != nil { + panic(fmt.Errorf("simplelru: %w (size=%d)", err, size)) + } + return &SimpleCache[K, V]{c: c} +} + +func (l *SimpleCache[K, V]) Add(key K, value V) (evicted bool) { return l.c.Add(key, value) } +func (l *SimpleCache[K, V]) Get(key K) (V, bool) { return l.c.Get(key) } +func (l *SimpleCache[K, V]) Remove(key K) (present bool) { return l.c.Remove(key) } +func (l *SimpleCache[K, V]) Purge() { l.c.Purge() } +func (l *SimpleCache[K, V]) Len() int { return l.c.Len() } diff --git a/utils/lru/lru_bench_test.go b/utils/lru/lru_bench_test.go new file mode 100644 index 0000000000..b72dc48f19 --- /dev/null +++ b/utils/lru/lru_bench_test.go @@ -0,0 +1,309 @@ +package lru + +import ( + "math/rand/v2" + "testing" + + gethlru "github.com/ethereum/go-ethereum/common/lru" + hashilru "github.com/hashicorp/golang-lru/v2" + hashisimple "github.com/hashicorp/golang-lru/v2/simplelru" +) + +// Paired benchmarks: ethereum/common/lru (old) vs hashicorp/golang-lru/v2 (new). +// Key shapes match real callsites: +// - int baseline +// - bloomKey {u64, u64} blockchain.EventFiltersCacheKey +// - feltKey [4]uint64 rpccore.TraceCacheKey (felt.Felt underlies as [4]uint64) +// +// Sizes mirror production: +// - 16 AggregatedBloomFilterCacheSize +// - 128 TraceCacheSize +// - 8192 stress +// +// Run with: go test -bench=. -benchmem -count=10 ./utils/lru/ + +type bloomKey struct { + from uint64 + to uint64 +} + +type feltKey [4]uint64 + +// generic cache interface so benchmarks can be written once. +type lruCache[K comparable] interface { + Add(key K, value int) (evicted bool) + Get(key K) (value int, ok bool) +} + +// adapters + +type gethCache[K comparable] struct{ c *gethlru.Cache[K, int] } + +func (g gethCache[K]) Add(k K, v int) bool { return g.c.Add(k, v) } +func (g gethCache[K]) Get(k K) (int, bool) { return g.c.Get(k) } + +type hashiCache[K comparable] struct{ c *hashilru.Cache[K, int] } + +func (h hashiCache[K]) Add(k K, v int) bool { evicted := h.c.Add(k, v); return evicted } +func (h hashiCache[K]) Get(k K) (int, bool) { return h.c.Get(k) } + +func newGeth[K comparable](size int) lruCache[K] { + return gethCache[K]{c: gethlru.NewCache[K, int](size)} +} + +func newHashi[K comparable](size int) lruCache[K] { + c, err := hashilru.New[K, int](size) + if err != nil { + panic(err) + } + return hashiCache[K]{c: c} +} + +// key generators + +func intKey(i int) int { return i } +func bloomFromInt(i int) bloomKey { return bloomKey{from: uint64(i), to: uint64(i) + 1024} } +func feltFromInt(i int) feltKey { + return feltKey{uint64(i), uint64(i) >> 1, uint64(i) << 1, ^uint64(i)} +} + +// per-impl, per-keytype matrix runner + +type implCase[K comparable] struct { + name string + make func(int) lruCache[K] +} + +func impls[K comparable]() []implCase[K] { + return []implCase[K]{ + {name: "geth", make: newGeth[K]}, + {name: "hashi", make: newHashi[K]}, + } +} + +// --- Add-only (write-heavy with evictions) --- + +func benchAdd[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + c.Add(gen(i), i) + } + }) + } +} + +func BenchmarkAdd_Int_128(b *testing.B) { benchAdd[int](b, 128, intKey) } +func BenchmarkAdd_Int_8192(b *testing.B) { benchAdd[int](b, 8192, intKey) } +func BenchmarkAdd_Bloom_16(b *testing.B) { benchAdd[bloomKey](b, 16, bloomFromInt) } +func BenchmarkAdd_Felt_128(b *testing.B) { benchAdd[feltKey](b, 128, feltFromInt) } + +// --- Get hit (read-heavy, all keys present) --- + +func benchGetHit[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + _, _ = c.Get(gen(i % size)) + } + }) + } +} + +func BenchmarkGetHit_Int_128(b *testing.B) { benchGetHit[int](b, 128, intKey) } +func BenchmarkGetHit_Int_8192(b *testing.B) { benchGetHit[int](b, 8192, intKey) } +func BenchmarkGetHit_Bloom_16(b *testing.B) { benchGetHit[bloomKey](b, 16, bloomFromInt) } +func BenchmarkGetHit_Felt_128(b *testing.B) { benchGetHit[feltKey](b, 128, feltFromInt) } + +// --- Get miss (all lookups fail) --- + +func benchGetMiss[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + _, _ = c.Get(gen(size + i)) + } + }) + } +} + +func BenchmarkGetMiss_Int_128(b *testing.B) { benchGetMiss[int](b, 128, intKey) } +func BenchmarkGetMiss_Bloom_16(b *testing.B) { benchGetMiss[bloomKey](b, 16, bloomFromInt) } +func BenchmarkGetMiss_Felt_128(b *testing.B) { benchGetMiss[feltKey](b, 128, feltFromInt) } + +// --- Mixed 80/20 read/write with random keys over 2*size space (~50% hit rate) --- + +//nolint:dupl // benchSimpleMixed mirrors this shape over a different cache interface. +func benchMixed[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + rng := rand.New(rand.NewPCG(1, 2)) + space := size * 2 + b.ReportAllocs() + b.ResetTimer() + for range b.N { + k := rng.IntN(space) + if rng.IntN(10) < 8 { + _, _ = c.Get(gen(k)) + } else { + c.Add(gen(k), k) + } + } + }) + } +} + +func BenchmarkMixed_Int_128(b *testing.B) { benchMixed[int](b, 128, intKey) } +func BenchmarkMixed_Bloom_16(b *testing.B) { benchMixed[bloomKey](b, 16, bloomFromInt) } +func BenchmarkMixed_Felt_128(b *testing.B) { benchMixed[feltKey](b, 128, feltFromInt) } + +// --- Parallel mixed: measures lock contention --- + +func benchParallelMixed[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range impls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) + space := size * 2 + for pb.Next() { + k := rng.IntN(space) + if rng.IntN(10) < 8 { + _, _ = c.Get(gen(k)) + } else { + c.Add(gen(k), k) + } + } + }) + }) + } +} + +func BenchmarkParallel_Int_128(b *testing.B) { benchParallelMixed[int](b, 128, intKey) } +func BenchmarkParallel_Bloom_16(b *testing.B) { benchParallelMixed[bloomKey](b, 16, bloomFromInt) } +func BenchmarkParallel_Felt_128(b *testing.B) { benchParallelMixed[feltKey](b, 128, feltFromInt) } + +// --- Non-thread-safe variants (basiclru vs simplelru) --- +// Mirrors sync/pending_polling.go preLatest cache: felt key, size=10. +// No locks → measures pure data-structure cost. + +type simpleCache[K comparable] interface { + Add(key K, value int) bool + Get(key K) (int, bool) +} + +type gethBasic[K comparable] struct{ c gethlru.BasicLRU[K, int] } + +func (g *gethBasic[K]) Add(k K, v int) bool { return g.c.Add(k, v) } +func (g *gethBasic[K]) Get(k K) (int, bool) { return g.c.Get(k) } + +type hashiSimple[K comparable] struct{ c *hashisimple.LRU[K, int] } + +func (h *hashiSimple[K]) Add(k K, v int) bool { return h.c.Add(k, v) } +func (h *hashiSimple[K]) Get(k K) (int, bool) { return h.c.Get(k) } + +func newGethBasic[K comparable](size int) simpleCache[K] { + c := gethlru.NewBasicLRU[K, int](size) + return &gethBasic[K]{c: c} +} + +func newHashiSimple[K comparable](size int) simpleCache[K] { + c, err := hashisimple.NewLRU[K, int](size, nil) + if err != nil { + panic(err) + } + return &hashiSimple[K]{c: c} +} + +type simpleImplCase[K comparable] struct { + name string + make func(int) simpleCache[K] +} + +func simpleImpls[K comparable]() []simpleImplCase[K] { + return []simpleImplCase[K]{ + {name: "geth-basic", make: newGethBasic[K]}, + {name: "hashi-simple", make: newHashiSimple[K]}, + } +} + +func benchSimpleAdd[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range simpleImpls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + c.Add(gen(i), i) + } + }) + } +} + +func benchSimpleGetHit[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range simpleImpls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + b.ReportAllocs() + b.ResetTimer() + for i := range b.N { + _, _ = c.Get(gen(i % size)) + } + }) + } +} + +//nolint:dupl // benchMixed mirrors this shape over a different cache interface. +func benchSimpleMixed[K comparable](b *testing.B, size int, gen func(int) K) { + for _, imp := range simpleImpls[K]() { + b.Run(imp.name, func(b *testing.B) { + c := imp.make(size) + for i := range size { + c.Add(gen(i), i) + } + rng := rand.New(rand.NewPCG(1, 2)) + space := size * 2 + b.ReportAllocs() + b.ResetTimer() + for range b.N { + k := rng.IntN(space) + if rng.IntN(10) < 8 { + _, _ = c.Get(gen(k)) + } else { + c.Add(gen(k), k) + } + } + }) + } +} + +func BenchmarkSimpleAdd_Felt_10(b *testing.B) { benchSimpleAdd[feltKey](b, 10, feltFromInt) } +func BenchmarkSimpleGetHit_Felt_10(b *testing.B) { benchSimpleGetHit[feltKey](b, 10, feltFromInt) } +func BenchmarkSimpleMixed_Felt_10(b *testing.B) { benchSimpleMixed[feltKey](b, 10, feltFromInt) } diff --git a/utils/lru/lru_test.go b/utils/lru/lru_test.go new file mode 100644 index 0000000000..cf0380ef69 --- /dev/null +++ b/utils/lru/lru_test.go @@ -0,0 +1,120 @@ +package lru + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//nolint:dupl // duplicate tests as there's identical APIs +func TestNew(t *testing.T) { + t.Run("returns usable cache for positive size", func(t *testing.T) { + c := New[string, int](2) + require.NotNil(t, c) + assert.Equal(t, 0, c.Len()) + + c.Add("a", 1) + c.Add("b", 2) + assert.Equal(t, 2, c.Len()) + + v, ok := c.Get("a") + assert.True(t, ok) + assert.Equal(t, 1, v) + }) + + t.Run("panics on zero size", func(t *testing.T) { + assert.PanicsWithError(t, "lru: must provide a positive size (size=0)", func() { + New[string, int](0) + }) + }) + + t.Run("panics on negative size", func(t *testing.T) { + assert.PanicsWithError(t, "lru: must provide a positive size (size=-1)", func() { + New[string, int](-1) + }) + }) +} + +func TestCache_Remove(t *testing.T) { + t.Run("removes present key", func(t *testing.T) { + c := New[string, int](2) + c.Add("a", 1) + c.Add("b", 2) + + assert.True(t, c.Remove("a")) + assert.Equal(t, 1, c.Len()) + + _, ok := c.Get("a") + assert.False(t, ok) + }) + + t.Run("returns false for missing key", func(t *testing.T) { + c := New[string, int](2) + c.Add("a", 1) + + assert.False(t, c.Remove("missing")) + assert.Equal(t, 1, c.Len()) + }) +} + +func TestCache_Purge(t *testing.T) { + c := New[string, int](3) + c.Add("a", 1) + c.Add("b", 2) + c.Add("c", 3) + + c.Purge() + assert.Equal(t, 0, c.Len()) + + _, ok := c.Get("a") + assert.False(t, ok) + + c.Add("d", 4) + assert.Equal(t, 1, c.Len()) +} + +//nolint:dupl // duplicate tests as there's identical APIs +func TestNewSimple(t *testing.T) { + t.Run("returns usable cache for positive size", func(t *testing.T) { + c := NewSimple[string, int](2) + require.NotNil(t, c) + assert.Equal(t, 0, c.Len()) + + c.Add("a", 1) + c.Add("b", 2) + assert.Equal(t, 2, c.Len()) + + v, ok := c.Get("a") + assert.True(t, ok) + assert.Equal(t, 1, v) + }) + + t.Run("panics on zero size", func(t *testing.T) { + assert.PanicsWithError(t, "simplelru: must provide a positive size (size=0)", func() { + NewSimple[string, int](0) + }) + }) + + t.Run("panics on negative size", func(t *testing.T) { + assert.PanicsWithError(t, "simplelru: must provide a positive size (size=-1)", func() { + NewSimple[string, int](-1) + }) + }) +} + +func TestSimpleCache_Purge(t *testing.T) { + c := NewSimple[string, int](3) + c.Add("a", 1) + c.Add("b", 2) + c.Add("c", 3) + + c.Purge() + assert.Equal(t, 0, c.Len()) + + _, ok := c.Get("a") + assert.False(t, ok) + + c.Add("d", 4) + assert.Equal(t, 1, c.Len()) +}