From 7dea42f11092b63a8334b3f3209c8a137507225e Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Wed, 18 Feb 2026 15:28:15 +0100 Subject: [PATCH 1/3] Introduce phase1 benchmark --- .github/workflows/benchmark.yml | 16 +++ .../executing/executor_benchmark_test.go | 124 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 block/internal/executing/executor_benchmark_test.go diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e34f61502..9e3715bae 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -40,3 +40,19 @@ jobs: alert-threshold: '150%' fail-on-alert: true comment-on-alert: true + + - name: Run Block Executor benchmarks + run: | + go test -bench=BenchmarkProduceBlock -benchmem -run='^$' \ + ./block/internal/executing/... > block_executor_output.txt + - name: Store Block Executor benchmark result + uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7 + with: + name: Block Executor Benchmark + tool: 'go' + output-file-path: block_executor_output.txt + auto-push: true + github-token: ${{ secrets.GITHUB_TOKEN }} + alert-threshold: '150%' + fail-on-alert: true + comment-on-alert: true diff --git a/block/internal/executing/executor_benchmark_test.go b/block/internal/executing/executor_benchmark_test.go new file mode 100644 index 000000000..ba3dd1746 --- /dev/null +++ b/block/internal/executing/executor_benchmark_test.go @@ -0,0 +1,124 @@ +package executing + +import ( + "context" + "crypto/rand" + "testing" + "time" + + "github.com/ipfs/go-datastore" + "github.com/ipfs/go-datastore/sync" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/block/internal/cache" + "github.com/evstack/ev-node/block/internal/common" + coreseq "github.com/evstack/ev-node/core/sequencer" + "github.com/evstack/ev-node/pkg/config" + "github.com/evstack/ev-node/pkg/genesis" + "github.com/evstack/ev-node/pkg/signer/noop" + "github.com/evstack/ev-node/pkg/store" + testmocks "github.com/evstack/ev-node/test/mocks" + "github.com/evstack/ev-node/types" +) + +func BenchmarkProduceBlock(b *testing.B) { + specs := map[string]struct { + txs [][]byte + }{ + "empty batch": { + txs: nil, + }, + "single tx": { + txs: [][]byte{[]byte("tx1")}, + }, + } + for name, spec := range specs { + b.Run(name, func(b *testing.B) { + exec := newBenchExecutor(b, spec.txs) + ctx := b.Context() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := exec.ProduceBlock(ctx); err != nil { + b.Fatalf("ProduceBlock: %v", err) + } + } + }) + } +} + +func newBenchExecutor(b *testing.B, txs [][]byte) *Executor { + b.Helper() + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + memStore := store.New(ds) + + cacheManager, err := cache.NewManager(config.DefaultConfig(), memStore, zerolog.Nop()) + require.NoError(b, err) + + // Generate signer without depending on *testing.T + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(b, err) + signerWrapper, err := noop.NewNoopSigner(priv) + require.NoError(b, err) + addr, err := signerWrapper.GetAddress() + require.NoError(b, err) + + cfg := config.DefaultConfig() + cfg.Node.BlockTime = config.DurationWrapper{Duration: 10 * time.Millisecond} + cfg.Node.MaxPendingHeadersAndData = 100000 + + gen := genesis.Genesis{ + ChainID: "bench-chain", + InitialHeight: 1, + StartTime: time.Now().Add(-time.Hour), + ProposerAddress: addr, + } + + mockExec := testmocks.NewMockExecutor(b) + mockSeq := testmocks.NewMockSequencer(b) + hb := common.NewMockBroadcaster[*types.P2PSignedHeader](b) + db := common.NewMockBroadcaster[*types.P2PData](b) + + hb.EXPECT().WriteToStoreAndBroadcast(mock.Anything, mock.Anything).Return(nil) + db.EXPECT().WriteToStoreAndBroadcast(mock.Anything, mock.Anything).Return(nil) + + exec, err := NewExecutor( + memStore, mockExec, mockSeq, signerWrapper, + cacheManager, common.NopMetrics(), cfg, gen, + hb, db, zerolog.Nop(), common.DefaultBlockOptions(), + make(chan error, 1), nil, + ) + require.NoError(b, err) + + // One-time init expectations + mockExec.EXPECT().InitChain(mock.Anything, mock.AnythingOfType("time.Time"), gen.InitialHeight, gen.ChainID). + Return([]byte("init_root"), nil).Once() + mockSeq.EXPECT().SetDAHeight(uint64(0)).Return().Once() + + require.NoError(b, exec.initializeState()) + + exec.ctx, exec.cancel = context.WithCancel(b.Context()) + b.Cleanup(func() { exec.cancel() }) + + // Loop expectations (unlimited calls) + lastBatchTime := gen.StartTime + mockSeq.EXPECT().GetNextBatch(mock.Anything, mock.AnythingOfType("sequencer.GetNextBatchRequest")). + RunAndReturn(func(ctx context.Context, req coreseq.GetNextBatchRequest) (*coreseq.GetNextBatchResponse, error) { + lastBatchTime = lastBatchTime.Add(cfg.Node.BlockTime.Duration) + return &coreseq.GetNextBatchResponse{ + Batch: &coreseq.Batch{Transactions: txs}, + Timestamp: lastBatchTime, + BatchData: txs, + }, nil + }) + mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, mock.Anything, mock.AnythingOfType("time.Time"), mock.Anything). + Return([]byte("new_root"), nil) + mockSeq.EXPECT().GetDAHeight().Return(uint64(0)) + + return exec +} From fc7f729ba60964c27bdb4db8fcc8b32e7bf30383 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Thu, 19 Feb 2026 15:35:00 +0100 Subject: [PATCH 2/3] Bench refactor --- .../executing/executor_benchmark_test.go | 98 ++++++++++++------- 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/block/internal/executing/executor_benchmark_test.go b/block/internal/executing/executor_benchmark_test.go index ba3dd1746..0eb2a3a0d 100644 --- a/block/internal/executing/executor_benchmark_test.go +++ b/block/internal/executing/executor_benchmark_test.go @@ -6,21 +6,22 @@ import ( "testing" "time" + "github.com/celestiaorg/go-header" "github.com/ipfs/go-datastore" "github.com/ipfs/go-datastore/sync" + pubsub "github.com/libp2p/go-libp2p-pubsub" "github.com/libp2p/go-libp2p/core/crypto" "github.com/rs/zerolog" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + coreexec "github.com/evstack/ev-node/core/execution" coreseq "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/pkg/signer/noop" "github.com/evstack/ev-node/pkg/store" - testmocks "github.com/evstack/ev-node/test/mocks" "github.com/evstack/ev-node/types" ) @@ -37,7 +38,7 @@ func BenchmarkProduceBlock(b *testing.B) { } for name, spec := range specs { b.Run(name, func(b *testing.B) { - exec := newBenchExecutor(b, spec.txs) + exec := newBenchExecutorWithStubs(b, spec.txs) ctx := b.Context() b.ReportAllocs() @@ -51,7 +52,8 @@ func BenchmarkProduceBlock(b *testing.B) { } } -func newBenchExecutor(b *testing.B, txs [][]byte) *Executor { +// newBenchExecutorWithStubs creates an Executor using zero-overhead stubs. +func newBenchExecutorWithStubs(b *testing.B, txs [][]byte) *Executor { b.Helper() ds := sync.MutexWrap(datastore.NewMapDatastore()) @@ -60,7 +62,6 @@ func newBenchExecutor(b *testing.B, txs [][]byte) *Executor { cacheManager, err := cache.NewManager(config.DefaultConfig(), memStore, zerolog.Nop()) require.NoError(b, err) - // Generate signer without depending on *testing.T priv, _, err := crypto.GenerateEd25519Key(rand.Reader) require.NoError(b, err) signerWrapper, err := noop.NewNoopSigner(priv) @@ -70,7 +71,7 @@ func newBenchExecutor(b *testing.B, txs [][]byte) *Executor { cfg := config.DefaultConfig() cfg.Node.BlockTime = config.DurationWrapper{Duration: 10 * time.Millisecond} - cfg.Node.MaxPendingHeadersAndData = 100000 + cfg.Node.MaxPendingHeadersAndData = 0 // disabled — avoids advancePastEmptyData store scans gen := genesis.Genesis{ ChainID: "bench-chain", @@ -79,46 +80,75 @@ func newBenchExecutor(b *testing.B, txs [][]byte) *Executor { ProposerAddress: addr, } - mockExec := testmocks.NewMockExecutor(b) - mockSeq := testmocks.NewMockSequencer(b) - hb := common.NewMockBroadcaster[*types.P2PSignedHeader](b) - db := common.NewMockBroadcaster[*types.P2PData](b) - - hb.EXPECT().WriteToStoreAndBroadcast(mock.Anything, mock.Anything).Return(nil) - db.EXPECT().WriteToStoreAndBroadcast(mock.Anything, mock.Anything).Return(nil) + stubExec := &stubExecClient{stateRoot: []byte("init_root")} + stubSeq := &stubSequencer{ + batchResp: &coreseq.GetNextBatchResponse{ + Batch: &coreseq.Batch{Transactions: txs}, + Timestamp: time.Now(), + BatchData: txs, + }, + } + hb := &stubBroadcaster[*types.P2PSignedHeader]{} + db := &stubBroadcaster[*types.P2PData]{} exec, err := NewExecutor( - memStore, mockExec, mockSeq, signerWrapper, + memStore, stubExec, stubSeq, signerWrapper, cacheManager, common.NopMetrics(), cfg, gen, hb, db, zerolog.Nop(), common.DefaultBlockOptions(), make(chan error, 1), nil, ) require.NoError(b, err) - // One-time init expectations - mockExec.EXPECT().InitChain(mock.Anything, mock.AnythingOfType("time.Time"), gen.InitialHeight, gen.ChainID). - Return([]byte("init_root"), nil).Once() - mockSeq.EXPECT().SetDAHeight(uint64(0)).Return().Once() - require.NoError(b, exec.initializeState()) exec.ctx, exec.cancel = context.WithCancel(b.Context()) b.Cleanup(func() { exec.cancel() }) - // Loop expectations (unlimited calls) - lastBatchTime := gen.StartTime - mockSeq.EXPECT().GetNextBatch(mock.Anything, mock.AnythingOfType("sequencer.GetNextBatchRequest")). - RunAndReturn(func(ctx context.Context, req coreseq.GetNextBatchRequest) (*coreseq.GetNextBatchResponse, error) { - lastBatchTime = lastBatchTime.Add(cfg.Node.BlockTime.Duration) - return &coreseq.GetNextBatchResponse{ - Batch: &coreseq.Batch{Transactions: txs}, - Timestamp: lastBatchTime, - BatchData: txs, - }, nil - }) - mockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, mock.Anything, mock.AnythingOfType("time.Time"), mock.Anything). - Return([]byte("new_root"), nil) - mockSeq.EXPECT().GetDAHeight().Return(uint64(0)) - return exec } + +// stubSequencer implements coreseq.Sequencer with fixed return values. +type stubSequencer struct { + batchResp *coreseq.GetNextBatchResponse +} + +func (s *stubSequencer) SubmitBatchTxs(context.Context, coreseq.SubmitBatchTxsRequest) (*coreseq.SubmitBatchTxsResponse, error) { + return nil, nil +} +func (s *stubSequencer) GetNextBatch(context.Context, coreseq.GetNextBatchRequest) (*coreseq.GetNextBatchResponse, error) { + return s.batchResp, nil +} +func (s *stubSequencer) VerifyBatch(context.Context, coreseq.VerifyBatchRequest) (*coreseq.VerifyBatchResponse, error) { + return nil, nil +} +func (s *stubSequencer) SetDAHeight(uint64) {} +func (s *stubSequencer) GetDAHeight() uint64 { return 0 } + +// stubExecClient implements coreexec.Executor with fixed return values. +type stubExecClient struct { + stateRoot []byte +} + +func (s *stubExecClient) InitChain(context.Context, time.Time, uint64, string) ([]byte, error) { + return s.stateRoot, nil +} +func (s *stubExecClient) GetTxs(context.Context) ([][]byte, error) { return nil, nil } +func (s *stubExecClient) ExecuteTxs(_ context.Context, _ [][]byte, _ uint64, _ time.Time, _ []byte) ([]byte, error) { + return s.stateRoot, nil +} +func (s *stubExecClient) SetFinal(context.Context, uint64) error { return nil } +func (s *stubExecClient) GetExecutionInfo(context.Context) (coreexec.ExecutionInfo, error) { + return coreexec.ExecutionInfo{}, nil +} +func (s *stubExecClient) FilterTxs(context.Context, [][]byte, uint64, uint64, bool) ([]coreexec.FilterStatus, error) { + return nil, nil +} + +// stubBroadcaster implements common.Broadcaster[H] with no-ops. +type stubBroadcaster[H header.Header[H]] struct{} + +func (s *stubBroadcaster[H]) WriteToStoreAndBroadcast(context.Context, H, ...pubsub.PubOpt) error { + return nil +} +func (s *stubBroadcaster[H]) Store() header.Store[H] { return nil } +func (s *stubBroadcaster[H]) Height() uint64 { return 0 } From a4fb81f52dad2dfea5c4d0d6fbb1d52007ac9ef6 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Thu, 19 Feb 2026 16:18:16 +0100 Subject: [PATCH 3/3] bench: fix monotonically-increasing timestamp + add 100-tx case --- .../executing/executor_benchmark_test.go | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/block/internal/executing/executor_benchmark_test.go b/block/internal/executing/executor_benchmark_test.go index 0eb2a3a0d..4c925a013 100644 --- a/block/internal/executing/executor_benchmark_test.go +++ b/block/internal/executing/executor_benchmark_test.go @@ -3,6 +3,8 @@ package executing import ( "context" "crypto/rand" + "fmt" + "sync/atomic" "testing" "time" @@ -35,6 +37,9 @@ func BenchmarkProduceBlock(b *testing.B) { "single tx": { txs: [][]byte{[]byte("tx1")}, }, + "100 txs": { + txs: createTxs(100), + }, } for name, spec := range specs { b.Run(name, func(b *testing.B) { @@ -81,13 +86,7 @@ func newBenchExecutorWithStubs(b *testing.B, txs [][]byte) *Executor { } stubExec := &stubExecClient{stateRoot: []byte("init_root")} - stubSeq := &stubSequencer{ - batchResp: &coreseq.GetNextBatchResponse{ - Batch: &coreseq.Batch{Transactions: txs}, - Timestamp: time.Now(), - BatchData: txs, - }, - } + stubSeq := &stubSequencer{txs: txs} hb := &stubBroadcaster[*types.P2PSignedHeader]{} db := &stubBroadcaster[*types.P2PData]{} @@ -107,16 +106,25 @@ func newBenchExecutorWithStubs(b *testing.B, txs [][]byte) *Executor { return exec } -// stubSequencer implements coreseq.Sequencer with fixed return values. +// stubSequencer implements coreseq.Sequencer. +// GetNextBatch returns a monotonically-increasing timestamp on every call so +// that successive ProduceBlock iterations pass AssertValidSequence. type stubSequencer struct { - batchResp *coreseq.GetNextBatchResponse + txs [][]byte + counter atomic.Int64 // incremented each call; used to advance the timestamp } func (s *stubSequencer) SubmitBatchTxs(context.Context, coreseq.SubmitBatchTxsRequest) (*coreseq.SubmitBatchTxsResponse, error) { return nil, nil } func (s *stubSequencer) GetNextBatch(context.Context, coreseq.GetNextBatchRequest) (*coreseq.GetNextBatchResponse, error) { - return s.batchResp, nil + n := s.counter.Add(1) + ts := time.Now().Add(time.Duration(n) * time.Millisecond) + return &coreseq.GetNextBatchResponse{ + Batch: &coreseq.Batch{Transactions: s.txs}, + Timestamp: ts, + BatchData: s.txs, + }, nil } func (s *stubSequencer) VerifyBatch(context.Context, coreseq.VerifyBatchRequest) (*coreseq.VerifyBatchResponse, error) { return nil, nil @@ -124,6 +132,14 @@ func (s *stubSequencer) VerifyBatch(context.Context, coreseq.VerifyBatchRequest) func (s *stubSequencer) SetDAHeight(uint64) {} func (s *stubSequencer) GetDAHeight() uint64 { return 0 } +func createTxs(n int) [][]byte { + txs := make([][]byte, n) + for i := 0; i < n; i++ { + txs[i] = []byte(fmt.Sprintf("tx%d", i)) + } + return txs +} + // stubExecClient implements coreexec.Executor with fixed return values. type stubExecClient struct { stateRoot []byte