Skip to content

perf(autobahn): prepare block n+1 while block n executes - #4459

Open
bdchatham wants to merge 3 commits into
mainfrom
brandon2/backport-prepare-pipeline-to-main
Open

bdchatham wants to merge 3 commits into
mainfrom
brandon2/backport-prepare-pipeline-to-main

Conversation

@bdchatham

@bdchatham bdchatham commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

This is a backport to main of #4272 (original giga-1 PR #4260) from the giga-1 PR stack #4270–#4273. That stack targeted stacked giga-1 branches and never reached main, except #4270, which landed as #4366. @codchen approved #4272 at 453eb65. Since that commit, FinalizeBlock keeps main's PrevRandao and phase constants, main's two-generation sender cache gains peek, the prepare stage moves to prepare.go, and app.go's package constants move to the top. It does not depend on the #4271 backport. Tracked in PLT-1378.

The execute loop fetches the next global block on its own goroutine and calls PrepareBlock on it. The EVM-only app decodes that block's transactions and recovers their senders while the current block executes. FinalizeBlock uses the prepared transactions only for the same height and hash, and decodes the block itself otherwise. The proposer is still read on the execute loop after the previous Commit.

This is not app-hash-breaking. A prepared block decodes to the same transactions and senders, and tests compare app hashes and results with and without preparation, including preparation running at the same time as FinalizeBlock. It does not lower main-loop cost on a CPU-bound host: in a local run the prepare phase of about 10 ms left FinalizeBlock, but the execute phase grew by about the same amount because both stages use every core, which matches giga-testnet-2 (19.44 ms per block before the revert, 19.43 ms after). A prepared block is kept until its height executes, at most two at a time, so the fetcher can run a block ahead during catch-up without dropping the block before it; a router test checks that every block reaches FinalizeBlock prepared. A GlobalBlock error for n+1 now cancels block n mid-FinalizeBlock instead of one block later; the node errors out either way and n re-executes on restart. It merges cleanly with #4452 and the #4271 backport in any order.

The execute loop fetches the next global block on its own goroutine and
calls PrepareBlock on it, so the evmonly app decodes its transactions and
recovers their senders while the current block executes. FinalizeBlock uses
the prepared transactions only for the same height and hash, and decodes
the block itself otherwise. The proposer is still read on the execute loop,
after the previous block's Commit.

Backport of #4272 (giga-1 #4260). Adapted to main: the two-generation sender
cache gains peek, FinalizeBlock keeps main's PrevRandao and phase constants,
the prepare stage lives in prepare.go, and the app's package constants move
to the top of the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@cursor

cursor Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core block execution ordering and concurrency in the router and EVM-only finalize path; behavior is heavily tested for determinism but failure/cancellation timing for fetch errors during FinalizeBlock shifts slightly.

Overview
Adds ahead-of-time block preparation so transaction decode and sender recovery can overlap with executing the current block.

The ABCI proxy exposes optional PrepareBlock; the giga router execute loop runs a fetcher goroutine that loads the next global block, builds the FinalizeBlock request, calls PrepareBlock, and hands blocks to the main loop via an unbuffered channel (proposer address is still set on the execute loop after the previous Commit). The EVM-only app implements preparation in prepare.go: a bounded queue (two heights) stores decoded txs keyed by height and hash; FinalizeBlock consumes a match via ExecutePreparedBlock or falls back to the existing pipelined decode path. The CheckTx sender cache gains peek/peekSenders so preparation does not consume entries until execution.

OTel counters and phase timers track prepare hits vs misses. Extensive tests assert identical app hashes and tx results with and without preparation, including concurrent prepare/finalize and router catch-up.

Reviewed by Cursor Bugbot for commit d3868c0. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.27607% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.82%. Comparing base (880848f) to head (d3868c0).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
sei-tendermint/internal/p2p/giga_router_common.go 76.47% 11 Missing and 1 partial ⚠️
sei-tendermint/internal/proxy/proxy.go 0.00% 5 Missing ⚠️
sei-tendermint/internal/evmonlyapp/app.go 92.30% 4 Missing ⚠️
sei-tendermint/internal/evmonlyapp/prepare.go 94.54% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4459      +/-   ##
==========================================
+ Coverage   56.77%   56.82%   +0.04%     
==========================================
  Files        2126     2128       +2     
  Lines      166876   167121     +245     
==========================================
+ Hits        94737    94959     +222     
- Misses      72134    72157      +23     
  Partials        5        5              
Flag Coverage Δ
sei-chain 55.00% <85.27%> (+0.05%) ⬆️
sei-db 75.17% <ø> (ø)
sei-db-state-db 78.86% <ø> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-tendermint/internal/evmonlyapp/prepare.go 94.54% <94.54%> (ø)
sei-tendermint/internal/evmonlyapp/app.go 88.41% <92.30%> (+0.77%) ⬆️
sei-tendermint/internal/proxy/proxy.go 56.52% <0.00%> (-3.25%) ⬇️
sei-tendermint/internal/p2p/giga_router_common.go 80.13% <76.47%> (+0.36%) ⬆️

... and 32 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedOct 5, 2026, 10:27 PM

@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@seidroid seidroid Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The execute loop now fetches block n+1 and decodes it with PrepareBlock while block n executes. FinalizeBlock uses the prepared transactions only when the height and hash match, and decodes the block itself otherwise. I approve because a prepared block decodes to the same transactions and senders as an unprepared one (parsePreparedTx is deterministic for a given Number/Time signer), so preparing cannot change results; the one issue that holds affects performance only. I kept codex's single-slot overwrite finding as a suggestion because it costs repeated decoding, not correctness; no other reading was provided. I could not run the tests because there is no Go toolchain in the sandbox.

Non-blocking

1 finding on the changed lines, as inline comments.

  • No test runs the evmonly app's PrepareBlock through the router's fetch-ahead loop. The router tests use testApp, which does not implement blockPreparer. The app tests call PrepareBlock directly, so nothing checks that the pipeline actually produces hits, and the slot-overwrite case above goes undetected.
1 nit, not posted on the code
  • sei-tendermint/internal/evmonlyapp/app.go:791 — On a hit, forgetSenders hashes every transaction again on the execute loop just to drop cache entries, although peekSenders already computed those hashes in PrepareBlock. Storing the hashes in preparedBlock would take this work off the critical path that the change is trying to shorten.

seidroid review · decision approve · session 0c7b4d3668f34384acee423f608fc47d · turn resp_claude_ee77d3c47fb71b7b410dccc1699e05e0 · item d990d26818ab5ccb8e9b9c6cf1b09e7a

Findings: 0 blocking | 2 non-blocking | 1 posted inline

Comment thread sei-tendermint/internal/evmonlyapp/prepare.go Outdated
PrepareBlock keeps up to two prepared blocks, one per height, until
FinalizeBlock takes its block or the height falls below the next one to
execute. During catch-up the fetcher can prepare block n+1 before block n
reaches FinalizeBlock, and a single slot then dropped block n.

A prepared block also keeps its transaction hashes, so a hit drops the
CheckTx senders without hashing the block again on the execute loop.

A router test drives the fetch-ahead loop over a filled BlockStore with
an app that implements PrepareBlock, and checks every block arrives
prepared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@seidroid seidroid Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since my last review, d1818b4 replaced the single prepared slot with a height-ordered queue of at most two blocks, stored the transaction hashes so a prepared block no longer rehashes on the execute loop, and added a router test that runs the real fetch-ahead loop; the overwrite race, the rehash nit and the missing router test I raised are all fixed as the reply claims. Nothing blocks, because the fetcher cannot get more than one block ahead and the queue keeps the next committed height; of codex's single reading I kept the eviction-tail point as a nit, since it holds at most one stale block and is not a growing leak.

1 nit, not posted on the code
  • sei-tendermint/internal/evmonlyapp/prepare.go:50 — (codex, confirmed) Reslicing kept[:min(len(kept), maxPreparedBlocks)] leaves the evicted block in the backing array, so its txs stay reachable until a later append overwrites that slot. It is bounded to one block and the router never fills the queue past two, but clearing the tail before truncating (clear(kept[maxPreparedBlocks:])) would release it.

seidroid review · decision approve · session 0c7b4d3668f34384acee423f608fc47d · turn resp_claude_f2b7f22048fa176fa3437a0a526324ed · item 5ce82dc82b3750f2be7454174ffdbb3a

Findings: 0 blocking | 0 non-blocking | 0 posted inline

Clear the queue's tail before truncating, so an evicted block's transactions
stop being reachable through the backing array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@seidroid seidroid Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR has the execute loop prepare block n+1 while block n runs, keeping up to two prepared blocks in a height-ordered queue. The one change since my last review is that put now clears the evicted tail before truncating, with a test that checks it, so my eviction-retention nit (first raised by codex) is fixed. My earlier slot-overwrite finding was already fixed, and codex found nothing new, which I confirmed, so nothing blocks.

seidroid review · decision approve · session 0c7b4d3668f34384acee423f608fc47d · turn resp_claude_92e61fae57b4a1848cc8ba5b226bfb1d · item 98c1b3f240845b9ea2bd3c020a69826f

Findings: 0 blocking | 0 non-blocking | 0 posted inline

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant