Skip to content

Commit 84fe7e5

Browse files
lupin012claude
andauthored
rpc: eth_feeHistory optimisation (#19526)
This PR introduces two major optimizations inspired by the Geth/Netmind client architectures to decrease latency: * Parallel Block Processing: Switched from sequential to concurrent block fetching using 4 workers, matching Geth's maxBlockFetchers architecture. * FeeHistory Cache: Added a 2048-entry LRU cache for eth_feeHistory results. This avoids repetitive fee/percentile computations by caching the final processedFees struct instead of raw block data. <details> <summary><b>📊 Click to view detailed Benchmark results</b></summary> | Scenario | Before | After | Speedup | | :--- | :--- | :--- | :--- | | **full/200** (sequential cold) | 451 ms | 93 ms | **4.9×** | | **full/1024** (sequential cold) | 2,230 ms | 118 ms | **18.9×** | | **full/1024** (sequential warm) | 2,346 ms | 95 ms | **24.7×** | | **full/1024** (concurrent warm) | 1 req/s | 40 req/s | **40.0×** | </details> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b796ead commit 84fe7e5

File tree

6 files changed

+219
-78
lines changed

6 files changed

+219
-78
lines changed

rpc/gasprice/feehistory.go

Lines changed: 182 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,20 @@ package gasprice
2121

2222
import (
2323
"context"
24+
"encoding/binary"
2425
"errors"
2526
"fmt"
27+
"math"
2628
"math/big"
2729
"sort"
2830
"sync/atomic"
2931

3032
"github.com/holiman/uint256"
33+
"golang.org/x/sync/errgroup"
3134

3235
"github.com/erigontech/erigon/common"
36+
commonlru "github.com/erigontech/erigon/common/lru"
37+
"github.com/erigontech/erigon/execution/chain"
3338
"github.com/erigontech/erigon/execution/protocol/misc"
3439
"github.com/erigontech/erigon/execution/types"
3540
"github.com/erigontech/erigon/rpc"
@@ -46,8 +51,59 @@ const (
4651
maxFeeHistory = 1024
4752
// maxQueryLimit is the max number of requested percentiles.
4853
maxQueryLimit = 100
54+
// feeHistoryCacheSize is the number of processed block results to keep in LRU cache.
55+
feeHistoryCacheSize = 2048
56+
// maxBlockFetchers is the number of goroutines used in the parallel block fetch loop.
57+
maxBlockFetchers = 4
4958
)
5059

60+
// cacheKey identifies a processed block in the fee history cache.
61+
// The percentiles string is a binary encoding of the requested percentile slice,
62+
// so identical percentile arrays produce the same key.
63+
type cacheKey struct {
64+
number uint64
65+
percentiles string
66+
}
67+
68+
// processedFees holds the computed fee data for a single block.
69+
// This is what gets stored in the LRU cache.
70+
type processedFees struct {
71+
reward []*big.Int
72+
baseFee, nextBaseFee *uint256.Int
73+
blobBaseFee, nextBlobBaseFee *uint256.Int
74+
gasUsedRatio float64
75+
blobGasUsedRatio float64
76+
}
77+
78+
// FeeHistoryCache is an opaque LRU cache for fee history block data.
79+
// It is safe for concurrent use.
80+
type FeeHistoryCache struct {
81+
c *commonlru.Cache[cacheKey, processedFees]
82+
}
83+
84+
// NewFeeHistoryCache creates a new fee history cache.
85+
func NewFeeHistoryCache() *FeeHistoryCache {
86+
return &FeeHistoryCache{c: commonlru.NewCache[cacheKey, processedFees](feeHistoryCacheSize)}
87+
}
88+
89+
func (fc *FeeHistoryCache) get(k cacheKey) (processedFees, bool) {
90+
return fc.c.Get(k)
91+
}
92+
93+
func (fc *FeeHistoryCache) add(k cacheKey, v processedFees) {
94+
fc.c.Add(k, v)
95+
}
96+
97+
// encodePercentiles serializes a slice of float64 percentile values into a
98+
// binary string suitable for use as a cache key component.
99+
func encodePercentiles(percentiles []float64) string {
100+
b := make([]byte, 8*len(percentiles))
101+
for i, p := range percentiles {
102+
binary.LittleEndian.PutUint64(b[i*8:], math.Float64bits(p))
103+
}
104+
return string(b)
105+
}
106+
51107
// blockFees represents a single block for processing
52108
type blockFees struct {
53109
// set by the caller
@@ -56,12 +112,8 @@ type blockFees struct {
56112
block *types.Block // only set if reward percentiles are requested
57113
receipts types.Receipts
58114
// filled by processBlock
59-
reward []*big.Int
60-
baseFee, nextBaseFee *uint256.Int
61-
blobBaseFee, nextBlobBaseFee *uint256.Int
62-
gasUsedRatio float64
63-
blobGasUsedRatio float64
64-
err error
115+
results processedFees
116+
err error
65117
}
66118

67119
// txGasAndReward is sorted in ascending order based on reward
@@ -84,15 +136,14 @@ func (s sortGasAndReward) Less(i, j int) bool {
84136
// processBlock takes a blockFees structure with the blockNumber, the header and optionally
85137
// the block field filled in, retrieves the block from the backend if not present yet and
86138
// fills in the rest of the fields.
87-
func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
88-
chainconfig := oracle.backend.ChainConfig()
89-
if bf.baseFee = bf.header.BaseFee; bf.baseFee == nil {
90-
bf.baseFee = new(uint256.Int)
139+
func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64, chainconfig *chain.Config) {
140+
if bf.results.baseFee = bf.header.BaseFee; bf.results.baseFee == nil {
141+
bf.results.baseFee = new(uint256.Int)
91142
}
92143
if chainconfig.IsLondon(bf.blockNumber + 1) {
93-
bf.nextBaseFee = misc.CalcBaseFee(chainconfig, bf.header)
144+
bf.results.nextBaseFee = misc.CalcBaseFee(chainconfig, bf.header)
94145
} else {
95-
bf.nextBaseFee = new(uint256.Int)
146+
bf.results.nextBaseFee = new(uint256.Int)
96147
}
97148

98149
// Fill in blob base fee and next blob base fee.
@@ -108,17 +159,17 @@ func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
108159
bf.err = err
109160
return
110161
}
111-
bf.blobBaseFee = &blobBaseFee256
112-
bf.nextBlobBaseFee = &nextBlobBaseFee256
162+
bf.results.blobBaseFee = &blobBaseFee256
163+
bf.results.nextBlobBaseFee = &nextBlobBaseFee256
113164

114165
} else {
115-
bf.blobBaseFee = new(uint256.Int)
116-
bf.nextBlobBaseFee = new(uint256.Int)
166+
bf.results.blobBaseFee = new(uint256.Int)
167+
bf.results.nextBlobBaseFee = new(uint256.Int)
117168
}
118-
bf.gasUsedRatio = float64(bf.header.GasUsed) / float64(bf.header.GasLimit)
169+
bf.results.gasUsedRatio = float64(bf.header.GasUsed) / float64(bf.header.GasLimit)
119170

120171
if blobGasUsed := bf.header.BlobGasUsed; blobGasUsed != nil && chainconfig.GetMaxBlobGasPerBlock(bf.header.Time) != 0 {
121-
bf.blobGasUsedRatio = float64(*blobGasUsed) / float64(chainconfig.GetMaxBlobGasPerBlock(bf.header.Time))
172+
bf.results.blobGasUsedRatio = float64(*blobGasUsed) / float64(chainconfig.GetMaxBlobGasPerBlock(bf.header.Time))
122173
}
123174

124175
if len(percentiles) == 0 {
@@ -131,11 +182,11 @@ func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
131182
return
132183
}
133184

134-
bf.reward = make([]*big.Int, len(percentiles))
185+
bf.results.reward = make([]*big.Int, len(percentiles))
135186
if len(bf.block.Transactions()) == 0 {
136187
// return an all zero row if there are no transactions to gather data from
137-
for i := range bf.reward {
138-
bf.reward[i] = new(big.Int)
188+
for i := range bf.results.reward {
189+
bf.results.reward[i] = new(big.Int)
139190
}
140191
return
141192
}
@@ -160,7 +211,7 @@ func (oracle *Oracle) processBlock(bf *blockFees, percentiles []float64) {
160211
txIndex++
161212
sumGasUsed += sorter[txIndex].gasUsed
162213
}
163-
bf.reward[i] = sorter[txIndex].reward
214+
bf.results.reward[i] = sorter[txIndex].reward
164215
}
165216
}
166217

@@ -273,61 +324,126 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks int, unresolvedLast
273324
}
274325
oldestBlock := lastBlock + 1 - uint64(blocks)
275326

327+
// percentileKey is the binary-encoded percentile slice used as part of the cache key.
328+
percentileKey := encodePercentiles(rewardPercentiles)
329+
330+
// blockResult holds the computed data for a single block slot.
331+
type blockResult struct {
332+
processed processedFees
333+
hasResult bool
334+
missing bool
335+
}
336+
276337
var (
277-
next = oldestBlock
278-
)
279-
var (
280-
reward = make([][]*big.Int, blocks)
281-
baseFee = make([]*uint256.Int, blocks+1)
282-
gasUsedRatio = make([]float64, blocks)
283-
blobGasUsedRatio = make([]float64, blocks)
284-
blobBaseFee = make([]*uint256.Int, blocks+1)
285-
firstMissing = blocks
338+
next = oldestBlock
339+
blockResults = make([]blockResult, blocks)
340+
reward = make([][]*big.Int, blocks)
341+
baseFee = make([]*uint256.Int, blocks+1)
342+
gasUsedRatio = make([]float64, blocks)
343+
blobBaseFee = make([]*uint256.Int, blocks+1)
286344
)
287-
for ; blocks > 0; blocks-- {
288-
if err = common.Stopped(ctx.Done()); err != nil {
289-
return common.Big0, nil, nil, nil, nil, nil, err
290-
}
291-
// Retrieve the next block number to fetch with this goroutine
292-
blockNumber := atomic.AddUint64(&next, 1) - 1
293-
if blockNumber > lastBlock {
294-
continue
295-
}
296345

297-
fees := &blockFees{blockNumber: blockNumber}
298-
if pendingBlock != nil && blockNumber >= pendingBlock.NumberU64() {
299-
fees.block, fees.receipts = pendingBlock, pendingReceipts
300-
} else {
301-
if len(rewardPercentiles) != 0 {
302-
fees.block, fees.err = oracle.backend.BlockByNumber(ctx, rpc.BlockNumber(blockNumber))
303-
if fees.block != nil && fees.err == nil {
304-
fees.receipts, fees.err = oracle.backend.GetReceiptsGasUsed(ctx, fees.block)
346+
// Pre-fetch chain config once using the main backend (safe: single goroutine).
347+
chainconfig := oracle.backend.ChainConfig()
348+
349+
// Launch up to maxBlockFetchers goroutines. Each goroutine opens its own
350+
// TemporalTx via Fork so MDBX transactions are never shared across goroutines.
351+
// If Fork returns nil (not supported), we fall back to using the main backend
352+
// sequentially (only one goroutine will do real work in that case because they
353+
// all share the same backend, but correctness is preserved).
354+
g, fetchCtx := errgroup.WithContext(ctx)
355+
for range maxBlockFetchers {
356+
g.Go(func() error {
357+
localBackend, cleanup, forkErr := oracle.backend.Fork(fetchCtx)
358+
if forkErr != nil {
359+
return forkErr
360+
}
361+
if cleanup != nil {
362+
defer cleanup()
363+
}
364+
if localBackend == nil {
365+
localBackend = oracle.backend
366+
}
367+
368+
for {
369+
if fetchCtx.Err() != nil {
370+
return nil
371+
}
372+
blockNumber := atomic.AddUint64(&next, 1) - 1
373+
if blockNumber > lastBlock {
374+
return nil
375+
}
376+
idx := int(blockNumber - oldestBlock)
377+
378+
// Try the LRU cache first (skip for pending blocks — they are ephemeral).
379+
isPending := pendingBlock != nil && blockNumber >= pendingBlock.NumberU64()
380+
if !isPending && oracle.historyCache != nil {
381+
if cached, ok := oracle.historyCache.get(cacheKey{blockNumber, percentileKey}); ok {
382+
blockResults[idx] = blockResult{processed: cached, hasResult: true}
383+
continue
384+
}
385+
}
386+
387+
fees := &blockFees{blockNumber: blockNumber}
388+
if isPending {
389+
fees.block, fees.receipts = pendingBlock, pendingReceipts
390+
} else if len(rewardPercentiles) != 0 {
391+
fees.block, fees.err = localBackend.BlockByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
392+
if fees.block != nil && fees.err == nil {
393+
fees.receipts, fees.err = localBackend.GetReceiptsGasUsed(fetchCtx, fees.block)
394+
}
395+
} else {
396+
fees.header, fees.err = localBackend.HeaderByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
397+
}
398+
if fees.block != nil {
399+
fees.header = fees.block.Header()
400+
}
401+
if fees.err != nil {
402+
return fees.err
403+
}
404+
405+
if fees.header == nil {
406+
// No block and no error: requesting into the future (possible reorg).
407+
blockResults[idx].missing = true
408+
continue
409+
}
410+
411+
oracle.processBlock(fees, rewardPercentiles, chainconfig)
412+
if fees.err != nil {
413+
return fees.err
414+
}
415+
416+
blockResults[idx] = blockResult{processed: fees.results, hasResult: true}
417+
if !isPending && oracle.historyCache != nil {
418+
oracle.historyCache.add(cacheKey{blockNumber, percentileKey}, fees.results)
305419
}
306-
} else {
307-
fees.header, fees.err = oracle.backend.HeaderByNumber(ctx, rpc.BlockNumber(blockNumber))
308420
}
309-
}
310-
if fees.block != nil {
311-
fees.header = fees.block.Header()
312-
}
313-
if fees.header != nil {
314-
oracle.processBlock(fees, rewardPercentiles)
315-
}
421+
})
422+
}
423+
if err = g.Wait(); err != nil {
424+
return common.Big0, nil, nil, nil, nil, nil, err
425+
}
316426

317-
if fees.err != nil {
318-
return common.Big0, nil, nil, nil, nil, nil, fees.err
319-
}
320-
i := int(fees.blockNumber - oldestBlock)
321-
if fees.header != nil {
322-
reward[i], baseFee[i], baseFee[i+1], gasUsedRatio[i] = fees.reward, fees.baseFee, fees.nextBaseFee, fees.gasUsedRatio
323-
blobGasUsedRatio[i], blobBaseFee[i], blobBaseFee[i+1] = fees.blobGasUsedRatio, fees.blobBaseFee, fees.nextBlobBaseFee
324-
} else {
325-
// getting no block and no error means we are requesting into the future (might happen because of a reorg)
427+
// Post-processing is serial: all goroutines have finished, no races.
428+
firstMissing := len(blockResults)
429+
blobGasUsedRatio := make([]float64, len(blockResults))
430+
for i, r := range blockResults {
431+
if r.missing || !r.hasResult {
326432
if i < firstMissing {
327433
firstMissing = i
328434
}
435+
continue
329436
}
437+
p := &r.processed
438+
reward[i] = p.reward
439+
baseFee[i] = p.baseFee
440+
baseFee[i+1] = p.nextBaseFee
441+
gasUsedRatio[i] = p.gasUsedRatio
442+
blobGasUsedRatio[i] = p.blobGasUsedRatio
443+
blobBaseFee[i] = p.blobBaseFee
444+
blobBaseFee[i+1] = p.nextBlobBaseFee
330445
}
446+
331447
if firstMissing == 0 {
332448
return common.Big0, nil, nil, nil, nil, nil, nil
333449
}

rpc/gasprice/feehistory_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func TestFeeHistory(t *testing.T) {
8686
defer tx.Rollback()
8787

8888
cache := jsonrpc.NewGasPriceCache()
89-
oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(tx, baseApi), config, cache, log.New())
89+
oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(m.DB, tx, baseApi), config, cache, gasprice.NewFeeHistoryCache(), log.New())
9090

9191
first, reward, baseFee, ratio, blobBaseFee, blobBaseFeeRatio, err := oracle.FeeHistory(context.Background(), c.count, c.last, c.percent)
9292

rpc/gasprice/gasprice.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ type OracleBackend interface {
4545

4646
GetReceiptsGasUsed(ctx context.Context, block *types.Block) (types.Receipts, error)
4747
PendingBlockAndReceipts() (*types.Block, types.Receipts)
48+
49+
// Fork opens a new TemporalTx and returns a goroutine-local backend together
50+
// with a cleanup function (call via defer cleanup()).
51+
// If the backend does not support forking, it returns (nil, nil, nil) and
52+
// the caller should fall back to using the main backend sequentially.
53+
Fork(ctx context.Context) (OracleBackend, func(), error)
4854
}
4955

5056
type Cache interface {
@@ -55,10 +61,11 @@ type Cache interface {
5561
// Oracle recommends gas prices based on the content of recent
5662
// blocks. Suitable for both light and full clients.
5763
type Oracle struct {
58-
backend OracleBackend
59-
maxPrice *uint256.Int
60-
ignorePrice *uint256.Int
61-
cache Cache
64+
backend OracleBackend
65+
maxPrice *uint256.Int
66+
ignorePrice *uint256.Int
67+
cache Cache
68+
historyCache *FeeHistoryCache
6269

6370
checkBlocks int
6471
percentile int
@@ -69,7 +76,7 @@ type Oracle struct {
6976

7077
// NewOracle returns a new gasprice oracle which can recommend suitable
7178
// gasprice for newly created transaction.
72-
func NewOracle(backend OracleBackend, params gaspricecfg.Config, cache Cache, log log.Logger) *Oracle {
79+
func NewOracle(backend OracleBackend, params gaspricecfg.Config, cache Cache, historyCache *FeeHistoryCache, log log.Logger) *Oracle {
7380
blocks := params.Blocks
7481
if blocks < 1 {
7582
blocks = 1
@@ -104,6 +111,7 @@ func NewOracle(backend OracleBackend, params gaspricecfg.Config, cache Cache, lo
104111
checkBlocks: blocks,
105112
percentile: percent,
106113
cache: cache,
114+
historyCache: historyCache,
107115
maxHeaderHistory: params.MaxHeaderHistory,
108116
maxBlockHistory: params.MaxBlockHistory,
109117
log: log,

rpc/gasprice/gasprice_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func TestSuggestPrice(t *testing.T) {
9494
defer tx.Rollback()
9595

9696
cache := jsonrpc.NewGasPriceCache()
97-
oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(tx, baseApi), config, cache, log.New())
97+
oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(nil, tx, baseApi), config, cache, nil, log.New())
9898

9999
// The gas price sampled is: 32G, 31G, 30G, 29G, 28G, 27G
100100
got, err := oracle.SuggestTipCap(context.Background())

0 commit comments

Comments
 (0)