From 1db11554fcbb7164acd4cf2c1a4f64b1e2b5d803 Mon Sep 17 00:00:00 2001 From: Brandon Chatham Date: Sat, 19 Sep 2026 20:40:36 +0000 Subject: [PATCH 1/4] evmonlyapp: parse the next block on a quarter of the processors; time PrepareBlock Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- sei-tendermint/internal/evmonlyapp/app.go | 22 ++++++++++++++++++- .../internal/evmonlyapp/app_test.go | 7 ++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 9de73b706e..2fa48f02a5 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -55,6 +55,20 @@ const checkedSendersCap = 1 << 18 // minTxsPerHashWorker is the minimum transaction count assigned to a hash worker. const minTxsPerHashWorker = 64 +// parseWorkersShare is the fraction of GOMAXPROCS PrepareBlock decodes and +// recovers senders on; minParseWorkers is its floor on small hosts. +const ( + parseWorkersShare = 4 + minParseWorkers = 2 +) + +// parseWorkers returns the number of workers PrepareBlock decodes the next block +// on. It runs alongside the current block's OCC speculation, which holds a worker +// per processor, so it takes a quarter of them rather than contending for all. +func parseWorkers(procs int) int { + return max(minParseWorkers, procs/parseWorkersShare) +} + type evmOnlyApplication struct { abci.BaseApplication @@ -89,6 +103,9 @@ type evmOnlyApplication struct { prepared utils.Mutex[*utils.Option[preparedBlock]] // preparedBlocks counts finalized blocks by whether prepared held them. preparedBlocks otelmetric.Int64Counter + // preparePhases times PrepareBlock's decode of the next block. PrepareBlock is + // called from the single block fetcher, so one timer serves the app. + preparePhases *seidbmetrics.PhaseTimer } // preparedBlock is the stateless part of a FinalizeBlock request, computed before the @@ -152,6 +169,7 @@ func NewEVMOnlyApplication( finalizePhases: seidbmetrics.NewPhaseTimer(otel.Meter(finalizeMeterName), "evmonly_finalize"), prepared: utils.NewMutex(new(utils.Option[preparedBlock])), preparedBlocks: newPreparedBlocksCounter(otel.Meter(finalizeMeterName)), + preparePhases: seidbmetrics.NewPhaseTimer(otel.Meter(finalizeMeterName), "evmonly_prepare"), settler: utils.NewAtomicSend(utils.None[*evmonly.Executor]()), cursor: utils.NewMutex(&evmOnlyCursorState{}), checkedSenders: utils.NewMutex(map[common.Hash]common.Address{}), @@ -196,7 +214,7 @@ func (a *evmOnlyApplication) newExecutor() *evmonly.Executor { ChainConfig: a.chainConfig, MinGasPrice: big.NewInt(evmOnlyMinGasPrice), OCCWorkers: runtime.GOMAXPROCS(0), - ParseWorkers: runtime.GOMAXPROCS(0), + ParseWorkers: parseWorkers(runtime.GOMAXPROCS(0)), // Autobahn orders transactions without validating them, so a block can hold one // the executor cannot apply; failing the block would halt every validator. RejectUnappliableTxs: true, @@ -648,6 +666,8 @@ func (a *evmOnlyApplication) PrepareBlock(ctx context.Context, req *abci.Request } // Only Number and Time reach the decoded transactions (through the signer); the // parent-derived fields are filled in by FinalizeBlock. + a.preparePhases.SetPhase("parse") + defer a.preparePhases.Reset() prepared, err := executor.PrepareBlock(ctx, evmonly.BlockRequest{ Context: evmonly.BlockContext{ Number: block.number, diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index 7eff0fa9ed..f41f7e05f0 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -841,6 +841,13 @@ func TestEVMOnlyApplicationReportsAnUndecodableBlockFromFinalizeBlock(t *testing require.Error(t, unpreparedErr) } +func TestParseWorkersTakesAShareOfTheProcessorsWithAFloor(t *testing.T) { + require.Equal(t, minParseWorkers, parseWorkers(1)) + require.Equal(t, minParseWorkers, parseWorkers(4)) + require.Equal(t, 2, parseWorkers(11)) + require.Equal(t, 8, parseWorkers(32)) +} + // Preparing before InitChain is a no-op rather than a failure. func TestEVMOnlyApplicationPrepareBlockBeforeInitChainIsANoOp(t *testing.T) { app := newEVMOnlyTestApp(t, nil) From 4b01e0f3172b3c3a61f29590b9d05eac173ec8f9 Mon Sep 17 00:00:00 2001 From: Brandon Chatham Date: Sat, 19 Sep 2026 20:45:45 +0000 Subject: [PATCH 2/4] evmonlyapp: note the unprepared FinalizeBlock decode shares the parse pool Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- sei-tendermint/internal/evmonlyapp/app.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 2fa48f02a5..881fa08eee 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -65,6 +65,9 @@ const ( // parseWorkers returns the number of workers PrepareBlock decodes the next block // on. It runs alongside the current block's OCC speculation, which holds a worker // per processor, so it takes a quarter of them rather than contending for all. +// The same pool decodes a block FinalizeBlock finds unprepared, where nothing +// competes for the processors; that path is rare enough that the slower decode +// is accepted over a second pool. func parseWorkers(procs int) int { return max(minParseWorkers, procs/parseWorkersShare) } From a5fad63018c6364c6da6e49f8c88ed0f3f58011e Mon Sep 17 00:00:00 2001 From: Brandon Chatham Date: Sun, 20 Sep 2026 00:41:21 +0000 Subject: [PATCH 3/4] evmonly: size the next block's decode from its cost and the time it has, not a fixed share of the processors PrepareBlockWithin picks the fewest parse workers the block's decode is expected to fit in a budget on, from a moving estimate of the per-tx cost measured on earlier decodes; evmonlyapp budgets half the previous block's execution time. No budget (the unprepared FinalizeBlock path, or before the first block) decodes on every worker. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- giga/evmonly/executor.go | 19 +++++- giga/evmonly/parse_sizer.go | 59 +++++++++++++++++++ giga/evmonly/parse_sizer_test.go | 52 ++++++++++++++++ sei-tendermint/internal/evmonlyapp/app.go | 38 ++++++------ .../internal/evmonlyapp/app_test.go | 8 +-- 5 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 giga/evmonly/parse_sizer.go create mode 100644 giga/evmonly/parse_sizer_test.go diff --git a/giga/evmonly/executor.go b/giga/evmonly/executor.go index ca03f6fb1f..88c21964f5 100644 --- a/giga/evmonly/executor.go +++ b/giga/evmonly/executor.go @@ -6,6 +6,7 @@ import ( "math/big" "sync" "sync/atomic" + "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core" @@ -29,6 +30,7 @@ type Executor struct { cfg Config resultSink ResultSink occPool *occWorkerPool + parseSizer *parseSizer resultPool *blockResultPool stateDBPool sync.Pool storeMu sync.Mutex @@ -102,6 +104,7 @@ func NewExecutor(cfg Config, opts ...Option) *Executor { resultPool: newBlockResultPool(cfg.BlockResultPoolSize), blockPhases: seidbmetrics.NewPhaseTimer(otel.Meter(executorMeterName), "evmonly_block"), } + e.parseSizer = newParseSizer(e.cfg.ParseWorkers) if e.cfg.OCCWorkers > 1 { e.occPool = newOCCWorkerPool(e.cfg.OCCWorkers) } @@ -176,7 +179,18 @@ func (e *Executor) ExecuteBlock(ctx context.Context, req BlockRequest) (*BlockRe return result, nil } +// PrepareBlock decodes the block's transactions and recovers their senders on +// every parse worker. func (e *Executor) PrepareBlock(ctx context.Context, req BlockRequest) (PreparedBlock, error) { + return e.PrepareBlockWithin(ctx, req, 0) +} + +// PrepareBlockWithin decodes the block's transactions and recovers their senders +// on as few parse workers as the decode is expected to fit in budget on, leaving +// the rest of the processors to whatever runs alongside. A budget of 0 uses every +// parse worker. The expectation comes from the decodes before this one, so the +// first block on an executor is decoded on every worker. +func (e *Executor) PrepareBlockWithin(ctx context.Context, req BlockRequest, budget time.Duration) (PreparedBlock, error) { chainConfig := e.chainConfig(req.Context) if err := validateBlockContext(chainConfig, req.Context); err != nil { return PreparedBlock{}, err @@ -185,10 +199,13 @@ func (e *Executor) PrepareBlock(ctx context.Context, req BlockRequest) (Prepared if len(req.Senders) != 0 && len(req.Senders) != len(req.Txs) { return PreparedBlock{}, fmt.Errorf("block request has %d senders for %d txs", len(req.Senders), len(req.Txs)) } - parsed, err := parseBlockTxs(ctx, req.Txs, signer, req.Senders, e.cfg.ParseWorkers) + workers := e.parseSizer.workers(len(req.Txs), budget) + start := time.Now() + parsed, err := parseBlockTxs(ctx, req.Txs, signer, req.Senders, workers) if err != nil { return PreparedBlock{}, err } + e.parseSizer.observe(len(req.Txs), workers, time.Since(start)) return PreparedBlock{ Context: req.Context, Txs: parsed, diff --git a/giga/evmonly/parse_sizer.go b/giga/evmonly/parse_sizer.go new file mode 100644 index 0000000000..6d4d5266c3 --- /dev/null +++ b/giga/evmonly/parse_sizer.go @@ -0,0 +1,59 @@ +package evmonly + +import ( + "sync/atomic" + "time" +) + +// perTxCostWeight is the denominator of the exponential moving average of the +// per-transaction decode cost; each block moves the estimate 1/perTxCostWeight +// of the way to what it measured. +const perTxCostWeight = 8 + +// parseSizer picks how many workers decode a block from the block's size, the +// time available to decode it, and a running estimate of the per-transaction +// decode cost measured on the blocks before it. +type parseSizer struct { + maxWorkers int + // perTx is the estimated processor time to decode one transaction, in + // nanoseconds. 0 until the first block has been measured. + perTx atomic.Int64 +} + +func newParseSizer(maxWorkers int) *parseSizer { + return &parseSizer{maxWorkers: max(maxWorkers, 1)} +} + +// workers returns how many workers decode txs transactions within budget: the +// fewest the estimated cost fits in, and every worker when there is no budget +// or no estimate yet. +func (s *parseSizer) workers(txs int, budget time.Duration) int { + if txs <= 1 { + return 1 + } + perTx := s.perTx.Load() + if budget <= 0 || perTx <= 0 { + return min(s.maxWorkers, txs) + } + needed := (int64(txs)*perTx + int64(budget) - 1) / int64(budget) + return int(max(1, min(needed, int64(min(s.maxWorkers, txs))))) +} + +// observe folds a decode of txs transactions on workers workers that took +// elapsed into the per-transaction cost estimate. +func (s *parseSizer) observe(txs, workers int, elapsed time.Duration) { + if txs <= 0 || workers <= 0 || elapsed <= 0 { + return + } + measured := int64(elapsed) * int64(workers) / int64(txs) + for { + current := s.perTx.Load() + next := measured + if current > 0 { + next = current + (measured-current)/perTxCostWeight + } + if s.perTx.CompareAndSwap(current, next) { + return + } + } +} diff --git a/giga/evmonly/parse_sizer_test.go b/giga/evmonly/parse_sizer_test.go new file mode 100644 index 0000000000..5470918eb1 --- /dev/null +++ b/giga/evmonly/parse_sizer_test.go @@ -0,0 +1,52 @@ +package evmonly + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestParseSizerUsesEveryWorkerUntilItHasAnEstimate(t *testing.T) { + sizer := newParseSizer(8) + require.Equal(t, 8, sizer.workers(1000, time.Millisecond)) + require.Equal(t, 5, sizer.workers(5, time.Millisecond)) + require.Equal(t, 1, sizer.workers(1, time.Millisecond)) + require.Equal(t, 1, sizer.workers(0, time.Millisecond)) +} + +func TestParseSizerFitsTheEstimatedCostInTheBudget(t *testing.T) { + sizer := newParseSizer(32) + // 1000 txs on 4 workers in 12.5ms: 50µs per tx. + sizer.observe(1000, 4, 12500*time.Microsecond) + + // 1000 txs is 50ms of work: 5 workers fit it in 10ms, 3 in 20ms, 1 in 50ms or more. + require.Equal(t, 5, sizer.workers(1000, 10*time.Millisecond)) + require.Equal(t, 3, sizer.workers(1000, 20*time.Millisecond)) + require.Equal(t, 1, sizer.workers(1000, 50*time.Millisecond)) + require.Equal(t, 1, sizer.workers(1000, time.Second)) + // A block too large for the budget gets every worker, but never more than it has txs. + require.Equal(t, 32, sizer.workers(5000, time.Millisecond)) + require.Equal(t, 10, sizer.workers(10, time.Microsecond)) +} + +func TestParseSizerWithoutABudgetUsesEveryWorker(t *testing.T) { + sizer := newParseSizer(8) + sizer.observe(1000, 8, time.Millisecond) + require.Equal(t, 8, sizer.workers(1000, 0)) + require.Equal(t, 8, sizer.workers(1000, -time.Second)) +} + +func TestParseSizerFollowsTheMeasuredCost(t *testing.T) { + sizer := newParseSizer(64) + sizer.observe(1000, 1, 10*time.Millisecond) + require.Equal(t, int64(10*time.Microsecond), sizer.perTx.Load()) + // The estimate moves a step towards each new measurement rather than jumping. + sizer.observe(1000, 1, 90*time.Millisecond) + require.Equal(t, int64(20*time.Microsecond), sizer.perTx.Load()) + // Zero-sized observations are ignored. + sizer.observe(0, 1, time.Millisecond) + sizer.observe(1000, 0, time.Millisecond) + sizer.observe(1000, 1, 0) + require.Equal(t, int64(20*time.Microsecond), sizer.perTx.Load()) +} diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 881fa08eee..030165042c 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -13,6 +13,7 @@ import ( "slices" "sync" "sync/atomic" + "time" "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" @@ -55,21 +56,19 @@ const checkedSendersCap = 1 << 18 // minTxsPerHashWorker is the minimum transaction count assigned to a hash worker. const minTxsPerHashWorker = 64 -// parseWorkersShare is the fraction of GOMAXPROCS PrepareBlock decodes and -// recovers senders on; minParseWorkers is its floor on small hosts. -const ( - parseWorkersShare = 4 - minParseWorkers = 2 -) +// prepareBudgetShare is the fraction of the previous block's execution time +// PrepareBlock is given to decode the next block in. Decoding runs alongside the +// current block's OCC speculation, which holds a worker per processor, so it is +// sized to finish within that block on as few processors as it can rather than +// contending for all of them; the share leaves room for the block being shorter +// than the last. +const prepareBudgetShare = 2 -// parseWorkers returns the number of workers PrepareBlock decodes the next block -// on. It runs alongside the current block's OCC speculation, which holds a worker -// per processor, so it takes a quarter of them rather than contending for all. -// The same pool decodes a block FinalizeBlock finds unprepared, where nothing -// competes for the processors; that path is rare enough that the slower decode -// is accepted over a second pool. -func parseWorkers(procs int) int { - return max(minParseWorkers, procs/parseWorkersShare) +// prepareBudget returns how long PrepareBlock has to decode the next block given +// how long the previous block took to execute. 0 when no block has executed yet, +// which decodes on every worker. +func prepareBudget(lastExecute time.Duration) time.Duration { + return lastExecute / prepareBudgetShare } type evmOnlyApplication struct { @@ -109,6 +108,9 @@ type evmOnlyApplication struct { // preparePhases times PrepareBlock's decode of the next block. PrepareBlock is // called from the single block fetcher, so one timer serves the app. preparePhases *seidbmetrics.PhaseTimer + // lastExecute is how long the most recent FinalizeBlock spent executing, in + // nanoseconds; PrepareBlock's decode budget is derived from it. + lastExecute atomic.Int64 } // preparedBlock is the stateless part of a FinalizeBlock request, computed before the @@ -217,7 +219,7 @@ func (a *evmOnlyApplication) newExecutor() *evmonly.Executor { ChainConfig: a.chainConfig, MinGasPrice: big.NewInt(evmOnlyMinGasPrice), OCCWorkers: runtime.GOMAXPROCS(0), - ParseWorkers: parseWorkers(runtime.GOMAXPROCS(0)), + ParseWorkers: runtime.GOMAXPROCS(0), // Autobahn orders transactions without validating them, so a block can hold one // the executor cannot apply; failing the block would halt every validator. RejectUnappliableTxs: true, @@ -671,7 +673,7 @@ func (a *evmOnlyApplication) PrepareBlock(ctx context.Context, req *abci.Request // parent-derived fields are filled in by FinalizeBlock. a.preparePhases.SetPhase("parse") defer a.preparePhases.Reset() - prepared, err := executor.PrepareBlock(ctx, evmonly.BlockRequest{ + prepared, err := executor.PrepareBlockWithin(ctx, evmonly.BlockRequest{ Context: evmonly.BlockContext{ Number: block.number, Time: block.timestamp, @@ -682,7 +684,7 @@ func (a *evmOnlyApplication) PrepareBlock(ctx context.Context, req *abci.Request }, Txs: req.Txs, Senders: a.peekSenders(req.Txs), - }) + }, prepareBudget(time.Duration(a.lastExecute.Load()))) if err != nil { return ctx.Err() } @@ -757,7 +759,9 @@ func (a *evmOnlyApplication) finalizeBlockLocked( a.finalizePhases.SetPhase("take_senders") a.forgetSenders(req.Txs) a.finalizePhases.SetPhase("execute") + start := time.Now() result, err = executor.ExecutePreparedBlock(ctx, evmonly.PreparedBlock{Context: blockCtx, Txs: prepared}) + a.lastExecute.Store(int64(time.Since(start))) } else { a.finalizePhases.SetPhase("take_senders") senders := a.takeSenders(req.Txs) diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index f41f7e05f0..f89bde2d60 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -841,11 +841,9 @@ func TestEVMOnlyApplicationReportsAnUndecodableBlockFromFinalizeBlock(t *testing require.Error(t, unpreparedErr) } -func TestParseWorkersTakesAShareOfTheProcessorsWithAFloor(t *testing.T) { - require.Equal(t, minParseWorkers, parseWorkers(1)) - require.Equal(t, minParseWorkers, parseWorkers(4)) - require.Equal(t, 2, parseWorkers(11)) - require.Equal(t, 8, parseWorkers(32)) +func TestPrepareBudgetIsAShareOfTheLastExecution(t *testing.T) { + require.Equal(t, time.Duration(0), prepareBudget(0)) + require.Equal(t, 10*time.Millisecond, prepareBudget(20*time.Millisecond)) } // Preparing before InitChain is a no-op rather than a failure. From 3ae1d461c83a64430c5cf7107ac13995581f0cdd Mon Sep 17 00:00:00 2001 From: Brandon Chatham Date: Sun, 20 Sep 2026 02:15:33 +0000 Subject: [PATCH 4/4] evmonly: budget the decode from the typical block, raise the cost estimate at once and lower it gradually Review follow-ups on the adaptive sizing: the budget is half an EWMA of the prepared blocks' execution time rather than half the last one, so a single short block does not hand the next decode every processor; the per-tx cost jumps up to any decode that overran its estimate and decays down, since an undersized decode delays the block that needs it; unbudgeted decodes (the unprepared FinalizeBlock path) no longer feed the estimate; the parse phase timer ends before the prepared slot is taken. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- giga/evmonly/executor.go | 8 ++-- giga/evmonly/parse_sizer.go | 24 +++++++---- giga/evmonly/parse_sizer_test.go | 9 ++-- sei-tendermint/internal/evmonlyapp/app.go | 41 +++++++++++++------ .../internal/evmonlyapp/app_test.go | 14 ++++++- 5 files changed, 68 insertions(+), 28 deletions(-) diff --git a/giga/evmonly/executor.go b/giga/evmonly/executor.go index 88c21964f5..5db07cbae0 100644 --- a/giga/evmonly/executor.go +++ b/giga/evmonly/executor.go @@ -188,8 +188,8 @@ func (e *Executor) PrepareBlock(ctx context.Context, req BlockRequest) (Prepared // PrepareBlockWithin decodes the block's transactions and recovers their senders // on as few parse workers as the decode is expected to fit in budget on, leaving // the rest of the processors to whatever runs alongside. A budget of 0 uses every -// parse worker. The expectation comes from the decodes before this one, so the -// first block on an executor is decoded on every worker. +// parse worker and does not inform the expectation, which comes from the budgeted +// decodes before this one; the first of those is decoded on every worker. func (e *Executor) PrepareBlockWithin(ctx context.Context, req BlockRequest, budget time.Duration) (PreparedBlock, error) { chainConfig := e.chainConfig(req.Context) if err := validateBlockContext(chainConfig, req.Context); err != nil { @@ -205,7 +205,9 @@ func (e *Executor) PrepareBlockWithin(ctx context.Context, req BlockRequest, bud if err != nil { return PreparedBlock{}, err } - e.parseSizer.observe(len(req.Txs), workers, time.Since(start)) + if budget > 0 { + e.parseSizer.observe(len(req.Txs), workers, time.Since(start)) + } return PreparedBlock{ Context: req.Context, Txs: parsed, diff --git a/giga/evmonly/parse_sizer.go b/giga/evmonly/parse_sizer.go index 6d4d5266c3..12d324d7b6 100644 --- a/giga/evmonly/parse_sizer.go +++ b/giga/evmonly/parse_sizer.go @@ -5,18 +5,22 @@ import ( "time" ) -// perTxCostWeight is the denominator of the exponential moving average of the -// per-transaction decode cost; each block moves the estimate 1/perTxCostWeight -// of the way to what it measured. -const perTxCostWeight = 8 +// perTxCostDecay is the denominator of the exponential moving average the +// per-transaction decode cost falls by; a block that measures below the estimate +// moves it 1/perTxCostDecay of the way down. A block that measures above it +// replaces it outright: a decode sized too small holds up the block that needs it, +// one sized too large only spends processors. +const perTxCostDecay = 8 // parseSizer picks how many workers decode a block from the block's size, the // time available to decode it, and a running estimate of the per-transaction // decode cost measured on the blocks before it. type parseSizer struct { maxWorkers int - // perTx is the estimated processor time to decode one transaction, in - // nanoseconds. 0 until the first block has been measured. + // perTx is the estimated worker time to decode one transaction, in + // nanoseconds: the wall time of a decode times the workers it ran on, per + // transaction, so it includes the share of the processors those workers + // were given. 0 until the first block has been measured. perTx atomic.Int64 } @@ -40,7 +44,9 @@ func (s *parseSizer) workers(txs int, budget time.Duration) int { } // observe folds a decode of txs transactions on workers workers that took -// elapsed into the per-transaction cost estimate. +// elapsed into the per-transaction cost estimate: a costlier decode than +// estimated raises the estimate to what it measured, a cheaper one lowers it +// gradually. func (s *parseSizer) observe(txs, workers int, elapsed time.Duration) { if txs <= 0 || workers <= 0 || elapsed <= 0 { return @@ -49,8 +55,8 @@ func (s *parseSizer) observe(txs, workers int, elapsed time.Duration) { for { current := s.perTx.Load() next := measured - if current > 0 { - next = current + (measured-current)/perTxCostWeight + if current > measured { + next = current - (current-measured)/perTxCostDecay } if s.perTx.CompareAndSwap(current, next) { return diff --git a/giga/evmonly/parse_sizer_test.go b/giga/evmonly/parse_sizer_test.go index 5470918eb1..cbf0139370 100644 --- a/giga/evmonly/parse_sizer_test.go +++ b/giga/evmonly/parse_sizer_test.go @@ -41,12 +41,15 @@ func TestParseSizerFollowsTheMeasuredCost(t *testing.T) { sizer := newParseSizer(64) sizer.observe(1000, 1, 10*time.Millisecond) require.Equal(t, int64(10*time.Microsecond), sizer.perTx.Load()) - // The estimate moves a step towards each new measurement rather than jumping. + // A costlier decode raises the estimate to what it measured at once. sizer.observe(1000, 1, 90*time.Millisecond) - require.Equal(t, int64(20*time.Microsecond), sizer.perTx.Load()) + require.Equal(t, int64(90*time.Microsecond), sizer.perTx.Load()) + // A cheaper one lowers it a step at a time. + sizer.observe(1000, 1, 10*time.Millisecond) + require.Equal(t, int64(80*time.Microsecond), sizer.perTx.Load()) // Zero-sized observations are ignored. sizer.observe(0, 1, time.Millisecond) sizer.observe(1000, 0, time.Millisecond) sizer.observe(1000, 1, 0) - require.Equal(t, int64(20*time.Microsecond), sizer.perTx.Load()) + require.Equal(t, int64(80*time.Microsecond), sizer.perTx.Load()) } diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 030165042c..b7e7d43245 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -56,19 +56,34 @@ const checkedSendersCap = 1 << 18 // minTxsPerHashWorker is the minimum transaction count assigned to a hash worker. const minTxsPerHashWorker = 64 -// prepareBudgetShare is the fraction of the previous block's execution time +// prepareBudgetShare is the fraction of the typical block execution time // PrepareBlock is given to decode the next block in. Decoding runs alongside the // current block's OCC speculation, which holds a worker per processor, so it is // sized to finish within that block on as few processors as it can rather than // contending for all of them; the share leaves room for the block being shorter -// than the last. +// than typical. const prepareBudgetShare = 2 +// executeEstimateDecay is the denominator of the exponential moving average of +// block execution time; each block moves the estimate 1/executeEstimateDecay of +// the way to what it took, so a single short block does not hand the next decode +// every processor. +const executeEstimateDecay = 8 + // prepareBudget returns how long PrepareBlock has to decode the next block given -// how long the previous block took to execute. 0 when no block has executed yet, -// which decodes on every worker. -func prepareBudget(lastExecute time.Duration) time.Duration { - return lastExecute / prepareBudgetShare +// the typical block execution time. 0 when no block has executed yet, which +// decodes on every worker. +func prepareBudget(executeEstimate time.Duration) time.Duration { + return executeEstimate / prepareBudgetShare +} + +// nextExecuteEstimate folds the execution time of a block into the estimate of the +// typical one. +func nextExecuteEstimate(current, executed time.Duration) time.Duration { + if current <= 0 { + return executed + } + return current + (executed-current)/executeEstimateDecay } type evmOnlyApplication struct { @@ -108,9 +123,11 @@ type evmOnlyApplication struct { // preparePhases times PrepareBlock's decode of the next block. PrepareBlock is // called from the single block fetcher, so one timer serves the app. preparePhases *seidbmetrics.PhaseTimer - // lastExecute is how long the most recent FinalizeBlock spent executing, in - // nanoseconds; PrepareBlock's decode budget is derived from it. - lastExecute atomic.Int64 + // executeEstimate is the typical time a prepared FinalizeBlock spends executing, + // in nanoseconds, averaged over the recent ones; PrepareBlock's decode budget is + // derived from it. Only prepared blocks contribute: an unprepared one includes + // its own decode. + executeEstimate atomic.Int64 } // preparedBlock is the stateless part of a FinalizeBlock request, computed before the @@ -672,7 +689,6 @@ func (a *evmOnlyApplication) PrepareBlock(ctx context.Context, req *abci.Request // Only Number and Time reach the decoded transactions (through the signer); the // parent-derived fields are filled in by FinalizeBlock. a.preparePhases.SetPhase("parse") - defer a.preparePhases.Reset() prepared, err := executor.PrepareBlockWithin(ctx, evmonly.BlockRequest{ Context: evmonly.BlockContext{ Number: block.number, @@ -684,7 +700,8 @@ func (a *evmOnlyApplication) PrepareBlock(ctx context.Context, req *abci.Request }, Txs: req.Txs, Senders: a.peekSenders(req.Txs), - }, prepareBudget(time.Duration(a.lastExecute.Load()))) + }, prepareBudget(time.Duration(a.executeEstimate.Load()))) + a.preparePhases.Reset() if err != nil { return ctx.Err() } @@ -761,7 +778,7 @@ func (a *evmOnlyApplication) finalizeBlockLocked( a.finalizePhases.SetPhase("execute") start := time.Now() result, err = executor.ExecutePreparedBlock(ctx, evmonly.PreparedBlock{Context: blockCtx, Txs: prepared}) - a.lastExecute.Store(int64(time.Since(start))) + a.executeEstimate.Store(int64(nextExecuteEstimate(time.Duration(a.executeEstimate.Load()), time.Since(start)))) } else { a.finalizePhases.SetPhase("take_senders") senders := a.takeSenders(req.Txs) diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index f89bde2d60..825eae2c65 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -841,11 +841,23 @@ func TestEVMOnlyApplicationReportsAnUndecodableBlockFromFinalizeBlock(t *testing require.Error(t, unpreparedErr) } -func TestPrepareBudgetIsAShareOfTheLastExecution(t *testing.T) { +func TestPrepareBudgetIsAShareOfTheTypicalExecution(t *testing.T) { require.Equal(t, time.Duration(0), prepareBudget(0)) require.Equal(t, 10*time.Millisecond, prepareBudget(20*time.Millisecond)) } +func TestExecuteEstimateSmoothsOverBlocks(t *testing.T) { + // The first block sets the estimate; later ones move it a step. + estimate := nextExecuteEstimate(0, 20*time.Millisecond) + require.Equal(t, 20*time.Millisecond, estimate) + // A single short block barely dents the budget rather than zeroing it. + estimate = nextExecuteEstimate(estimate, time.Millisecond) + require.Equal(t, 17625*time.Microsecond, estimate) + require.Equal(t, 8812500*time.Nanosecond, prepareBudget(estimate)) + estimate = nextExecuteEstimate(estimate, 40*time.Millisecond) + require.Equal(t, 20421875*time.Nanosecond, estimate) +} + // Preparing before InitChain is a no-op rather than a failure. func TestEVMOnlyApplicationPrepareBlockBeforeInitChainIsANoOp(t *testing.T) { app := newEVMOnlyTestApp(t, nil)