Skip to content

Commit e73c72f

Browse files
autobahn/evmonlyapp: prepare block n+1 while block n executes (#4260)
Second half of the sender-recovery pipelining, after #4259's measurement. Every block a validator executes arrives as raw tx bytes that must be RLP-decoded and ECDSA-recovered before the EVM can run them; CheckTx caches the sender only for txs this node admitted (~1/40 at 40 validators), so the rest is recovered inline in `FinalizeBlock` on the single-threaded Autobahn main loop (#4259's `prepare` phase). That work depends only on the bytes, not on state, so it does not have to wait its turn in the serial loop. The router's `runExecute` (`giga_router_common.go`) becomes a two-stage `scope.Run`: a fetcher task fetches `GlobalBlock(n)`, builds the state-independent part of the FinalizeBlock request (`finalizeRequest`: txs, hash, chain id, height, time), calls the new optional `app.PrepareBlock`, and hands the block over an unbuffered channel, so it is exactly one block ahead; the execute loop keeps `FinalizeBlock`, hash-vault commit, `Commit`, `PushAppHash` and prune in their existing order. `Header.ProposerAddress` is deliberately filled in by `executeBlock` (`proposerAddress()`) immediately before `FinalizeBlock`, because on the non-EVM-only path `GetValidators()` reads `BaseApp.checkState`, which `Commit` replaces; it must not be read from the fetcher. The proxy exposes `Proxy.PrepareBlock` through an optional `blockPreparer` interface (same pattern as `commitSettler`), a no-op for applications that do not implement it. In `evmonlyapp`, `PrepareBlock` reads the executor from `settler` (no executor lock, so it runs concurrently with `FinalizeBlock`), peeks the CheckTx sender cache without consuming it, runs `evmonly.Executor.PrepareBlock` (context validation + parse, touches no state) and stores `{height, hash, txs}` in a one-slot `prepared` mutex. `FinalizeBlock` uses the slot only when height and hash match, then forgets the cached senders and goes straight to `ExecutePreparedBlock`; on a mismatch the slot is left alone and the existing `take_senders`/`prepare`/`execute` path runs. `PrepareBlock` returns nothing but `ctx.Err()`, so any error that would fail the block is reproduced by `FinalizeBlock` and the failure path (`abandonPending`, router error) is unchanged. Only `Number`/`Time` reach the decoded txs (via the signer); the parent-derived block context (`ParentHash`, `PrevRandao`, `GasLimit`) is still filled in by `FinalizeBlock`, so preparing ahead cannot bake in stale cursor state. Invariants kept: block order, one commit in flight, background-commit failure surfacing on the next state-touching call, `PhaseTimer` touched only under the executor lock. Hits and misses are counted in `evmonly_finalize_prepared_blocks_total{prepared}`; the rollout check is that `prepared="true"` ≈ blocks/s and the main loop's `execution` share drops by roughly the former `prepare` phase; also watch the per-block `execution` phase itself, since block n+1's ECDSA recovery now competes for cores with block n's OCC workers and could offset part of the gain (the `consensus` slice now measures waiting on the fetcher, ~0 when the next block is already prepared). Validated with new `evmonlyapp` tests — prepared and unprepared blocks finalize to the same app hash and tx results, a wrong-hash preparation is bypassed and kept, an undecodable block prepared ahead is still reported by `FinalizeBlock`, preparing before InitChain is a no-op, and `PrepareBlock(n+1)` racing `FinalizeBlock(n)` over several blocks under `-race` matches a sequential run — plus the proxy and p2p suites, where `TestGigaRouter` now asserts the delivered blocks are contiguous and every header's `ProposerAddress` is a current validator, scoped golangci-lint and `make fmtcheck`. Link to Devin session: https://app.devin.ai/sessions/ff612badcded4aa5914ea408dbb41888 Open in Devin Desktop: https://app.devin.ai/desktop/session/ff612badcded4aa5914ea408dbb41888?variant=devin Requested by: @bdchatham --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 5565a61 commit e73c72f

6 files changed

Lines changed: 463 additions & 77 deletions

File tree

‎sei-tendermint/internal/evmonlyapp/app.go‎

Lines changed: 161 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
"sync"
1515
"sync/atomic"
1616

17+
"go.opentelemetry.io/otel/attribute"
18+
otelmetric "go.opentelemetry.io/otel/metric"
19+
1720
"github.com/ethereum/go-ethereum/common"
1821

1922
ethcore "github.com/ethereum/go-ethereum/core"
@@ -82,12 +85,35 @@ type evmOnlyApplication struct {
8285
// FinalizeBlock is serialized by executor, so one timer serves the app; it
8386
// is only touched with that lock held.
8487
finalizePhases *seidbmetrics.PhaseTimer
88+
// prepared holds the block PrepareBlock decoded ahead of FinalizeBlock, if any.
89+
prepared utils.Mutex[*utils.Option[preparedBlock]]
90+
// preparedBlocks counts finalized blocks by whether prepared held them.
91+
preparedBlocks otelmetric.Int64Counter
92+
}
93+
94+
// preparedBlock is the stateless part of a FinalizeBlock request, computed before the
95+
// request arrives. FinalizeBlock uses it only for the block with this height and hash.
96+
type preparedBlock struct {
97+
height int64
98+
hash common.Hash
99+
txs []evmonly.PreparedTx
85100
}
86101

87102
// finalizeMeterName is the OTel meter FinalizeBlock's phase timer records to,
88103
// as evmonly_finalize_phase_duration_seconds_total.
89104
const finalizeMeterName = "evmonly_app"
90105

106+
func newPreparedBlocksCounter(meter otelmetric.Meter) otelmetric.Int64Counter {
107+
counter, err := meter.Int64Counter(
108+
"evmonly_finalize_prepared_blocks_total",
109+
otelmetric.WithDescription("Finalized blocks by whether PrepareBlock had already decoded them"),
110+
)
111+
if err != nil {
112+
panic(fmt.Sprintf("evmonly_finalize_prepared_blocks_total: %v", err))
113+
}
114+
return counter
115+
}
116+
91117
// evmOnlyCursorState is the execution position: the block whose state is
92118
// committed to storage and the block finalized but not yet acknowledged by
93119
// Commit.
@@ -124,6 +150,8 @@ func NewEVMOnlyApplication(
124150
validators: slices.Clone(validators),
125151
executor: utils.NewMutex(new(utils.Option[*evmonly.Executor])),
126152
finalizePhases: seidbmetrics.NewPhaseTimer(otel.Meter(finalizeMeterName), "evmonly_finalize"),
153+
prepared: utils.NewMutex(new(utils.Option[preparedBlock])),
154+
preparedBlocks: newPreparedBlocksCounter(otel.Meter(finalizeMeterName)),
127155
settler: utils.NewAtomicSend(utils.None[*evmonly.Executor]()),
128156
cursor: utils.NewMutex(&evmOnlyCursorState{}),
129157
checkedSenders: utils.NewMutex(map[common.Hash]common.Address{}),
@@ -351,14 +379,30 @@ func (a *evmOnlyApplication) rememberSender(hash common.Hash, sender common.Addr
351379
// raw transaction is the keccak of its bytes for every transaction type, so no
352380
// decoding is needed.
353381
func (a *evmOnlyApplication) takeSenders(txs [][]byte) []utils.Option[common.Address] {
382+
return a.checkedSendersOf(txs, true)
383+
}
384+
385+
// peekSenders is takeSenders without forgetting the entries.
386+
func (a *evmOnlyApplication) peekSenders(txs [][]byte) []utils.Option[common.Address] {
387+
return a.checkedSendersOf(txs, false)
388+
}
389+
390+
// forgetSenders drops the CheckTx-recovered senders of txs.
391+
func (a *evmOnlyApplication) forgetSenders(txs [][]byte) {
392+
a.checkedSendersOf(txs, true)
393+
}
394+
395+
func (a *evmOnlyApplication) checkedSendersOf(txs [][]byte, forget bool) []utils.Option[common.Address] {
354396
out := make([]utils.Option[common.Address], len(txs))
355397
// Hashed outside the lock; CheckTx writes this map constantly.
356398
hashes := hashRawTxs(txs)
357399
for senders := range a.checkedSenders.Lock() {
358400
for i, hash := range hashes {
359401
if sender, ok := senders[hash]; ok {
360402
out[i] = utils.Some(sender)
361-
delete(senders, hash)
403+
if forget {
404+
delete(senders, hash)
405+
}
362406
}
363407
}
364408
}
@@ -556,26 +600,104 @@ func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message)
556600
}
557601
}
558602

559-
func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) {
603+
// finalizeRequest is the block identity FinalizeBlock and PrepareBlock derive from a request.
604+
type finalizeRequest struct {
605+
height int64
606+
number uint64
607+
timestamp uint64
608+
blockHash common.Hash
609+
}
610+
611+
func parseFinalizeRequest(req *abci.RequestFinalizeBlock) (finalizeRequest, error) {
560612
height := req.Header.Height
561613
if height <= 0 {
562-
return nil, fmt.Errorf("EVM-only block height must be positive: %d", height)
614+
return finalizeRequest{}, fmt.Errorf("EVM-only block height must be positive: %d", height)
563615
}
564616
number, ok := utils.SafeCast[uint64](height)
565617
if !ok {
566-
return nil, fmt.Errorf("EVM-only block height exceeds uint64: %d", height)
618+
return finalizeRequest{}, fmt.Errorf("EVM-only block height exceeds uint64: %d", height)
567619
}
568620
timestamp, ok := utils.SafeCast[uint64](req.Header.Time.Unix())
569621
if !ok {
570-
return nil, fmt.Errorf("EVM-only block timestamp is negative: %s", req.Header.Time)
622+
return finalizeRequest{}, fmt.Errorf("EVM-only block timestamp is negative: %s", req.Header.Time)
623+
}
624+
return finalizeRequest{
625+
height: height,
626+
number: number,
627+
timestamp: timestamp,
628+
blockHash: common.BytesToHash(req.Hash),
629+
}, nil
630+
}
631+
632+
// PrepareBlock decodes a block's transactions and recovers their senders before
633+
// FinalizeBlock is called for it, so that work runs while the previous block
634+
// executes. It may run concurrently with FinalizeBlock. Only the most recent
635+
// prepared block is kept, and FinalizeBlock uses it only for the same height and
636+
// hash, so preparing the wrong block costs nothing but the work: the senders
637+
// CheckTx cached stay cached until a prepared block is consumed. Anything that
638+
// would fail the block is left for FinalizeBlock to report; the only error
639+
// returned is ctx ending.
640+
func (a *evmOnlyApplication) PrepareBlock(ctx context.Context, req *abci.RequestFinalizeBlock) error {
641+
executor, ok := a.settler.Load().Get()
642+
if !ok {
643+
return nil
644+
}
645+
block, err := parseFinalizeRequest(req)
646+
if err != nil {
647+
return nil
648+
}
649+
// Only Number and Time reach the decoded transactions (through the signer); the
650+
// parent-derived fields are filled in by FinalizeBlock.
651+
prepared, err := executor.PrepareBlock(ctx, evmonly.BlockRequest{
652+
Context: evmonly.BlockContext{
653+
Number: block.number,
654+
Time: block.timestamp,
655+
GasLimit: a.EvmGasLimit(),
656+
ChainID: new(big.Int).Set(a.chainID),
657+
BaseFee: evmOnlyBaseFee(),
658+
BlobBaseFee: new(big.Int),
659+
},
660+
Txs: req.Txs,
661+
Senders: a.peekSenders(req.Txs),
662+
})
663+
if err != nil {
664+
return ctx.Err()
665+
}
666+
for slot := range a.prepared.Lock() {
667+
*slot = utils.Some(preparedBlock{
668+
height: block.height,
669+
hash: block.blockHash,
670+
txs: prepared.Txs,
671+
})
672+
}
673+
return nil
674+
}
675+
676+
// takePrepared returns the prepared transactions of the given block and removes
677+
// them. A prepared block for another block is left in place.
678+
func (a *evmOnlyApplication) takePrepared(height int64, hash common.Hash) ([]evmonly.PreparedTx, bool) {
679+
for slot := range a.prepared.Lock() {
680+
prepared, ok := slot.Get()
681+
if !ok || prepared.height != height || prepared.hash != hash {
682+
return nil, false
683+
}
684+
*slot = utils.None[preparedBlock]()
685+
return prepared.txs, true
686+
}
687+
panic("unreachable")
688+
}
689+
690+
func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) {
691+
block, err := parseFinalizeRequest(req)
692+
if err != nil {
693+
return nil, err
571694
}
572-
blockHash := common.BytesToHash(req.Hash)
573695
for executor := range a.executor.Lock() {
574696
executor, ok := executor.Get()
575697
if !ok {
576698
return nil, fmt.Errorf("EVM-only block finalized before InitChain")
577699
}
578-
return a.finalizeBlockLocked(ctx, executor, req, number, timestamp, blockHash)
700+
return a.finalizeBlockLocked(ctx, executor, req, block)
579701
}
580702
panic("unreachable")
581703
}
@@ -586,38 +708,47 @@ func (a *evmOnlyApplication) finalizeBlockLocked(
586708
ctx context.Context,
587709
executor *evmonly.Executor,
588710
req *abci.RequestFinalizeBlock,
589-
number, timestamp uint64,
590-
blockHash common.Hash,
711+
block finalizeRequest,
591712
) (*abci.ResponseFinalizeBlock, error) {
592-
height := req.Header.Height
593-
parent, err := a.beginBlock(height)
713+
parent, err := a.beginBlock(block.height)
594714
if err != nil {
595715
return nil, err
596716
}
717+
blockCtx := evmonly.BlockContext{
718+
Number: block.number,
719+
Time: block.timestamp,
720+
GasLimit: parent.gasLimit,
721+
ChainID: new(big.Int).Set(a.chainID),
722+
BaseFee: evmOnlyBaseFee(),
723+
BlobBaseFee: new(big.Int),
724+
ParentHash: parent.blockHash,
725+
BlockHash: block.blockHash,
726+
PrevRandao: parent.appHash,
727+
}
597728
// Closes the stage in flight, so the gap until the next block is charged to neither.
598729
defer a.finalizePhases.Reset()
599-
a.finalizePhases.SetPhase("take_senders")
600-
senders := a.takeSenders(req.Txs)
601-
result, err := executeBlockPipelined(ctx, executor, a.finalizePhases, evmonly.BlockRequest{
602-
Context: evmonly.BlockContext{
603-
Number: number,
604-
Time: timestamp,
605-
GasLimit: parent.gasLimit,
606-
ChainID: new(big.Int).Set(a.chainID),
607-
BaseFee: evmOnlyBaseFee(),
608-
BlobBaseFee: new(big.Int),
609-
ParentHash: parent.blockHash,
610-
BlockHash: blockHash,
611-
PrevRandao: parent.appHash,
612-
},
613-
Txs: req.Txs,
614-
Senders: senders,
615-
})
730+
prepared, hit := a.takePrepared(block.height, block.blockHash)
731+
a.preparedBlocks.Add(ctx, 1, otelmetric.WithAttributes(attribute.Bool("prepared", hit)))
732+
var result *evmonly.BlockResult
733+
if hit {
734+
a.finalizePhases.SetPhase("take_senders")
735+
a.forgetSenders(req.Txs)
736+
a.finalizePhases.SetPhase("execute")
737+
result, err = executor.ExecutePreparedBlock(ctx, evmonly.PreparedBlock{Context: blockCtx, Txs: prepared})
738+
} else {
739+
a.finalizePhases.SetPhase("take_senders")
740+
senders := a.takeSenders(req.Txs)
741+
result, err = executeBlockPipelined(ctx, executor, a.finalizePhases, evmonly.BlockRequest{
742+
Context: blockCtx,
743+
Txs: req.Txs,
744+
Senders: senders,
745+
})
746+
}
616747
if err != nil {
617-
return nil, errors.Join(err, a.abandonPending(executor, height))
748+
return nil, errors.Join(err, a.abandonPending(executor, block.height))
618749
}
619750
defer result.Release()
620-
pending, err := a.pendingCursor(height)
751+
pending, err := a.pendingCursor(block.height)
621752
if err != nil {
622753
return nil, err
623754
}

0 commit comments

Comments
 (0)