diff --git a/test/e2e/benchmark/gasburner_test.go b/test/e2e/benchmark/gasburner_test.go new file mode 100644 index 000000000..4cf5503e2 --- /dev/null +++ b/test/e2e/benchmark/gasburner_test.go @@ -0,0 +1,121 @@ +//go:build evm + +package benchmark + +import ( + "context" + "fmt" + "time" + + "github.com/celestiaorg/tastora/framework/docker/evstack/spamoor" +) + +// TestGasBurner measures gas throughput using a deterministic gasburner +// workload. The result is tracked via BENCH_JSON_OUTPUT as seconds_per_gigagas +// (lower is better) on the benchmark dashboard. +func (s *SpamoorSuite) TestGasBurner() { + const ( + numSpammers = 4 + countPerSpammer = 2500 + totalCount = numSpammers * countPerSpammer + warmupTxs = 50 + serviceName = "ev-node-gasburner" + waitTimeout = 5 * time.Minute + ) + + t := s.T() + ctx := t.Context() + w := newResultWriter(t, "GasBurner") + defer w.flush() + + e := s.setupEnv(config{ + serviceName: serviceName, + }) + api := e.spamoorAPI + + s.Require().NoError(deleteAllSpammers(api), "failed to delete stale spammers") + + gasburnerCfg := map[string]any{ + "gas_units_to_burn": 5_000_000, + "total_count": countPerSpammer, + "throughput": 25, + "max_pending": 5000, + "max_wallets": 500, + "rebroadcast": 0, + "base_fee": 20, + "tip_fee": 5, + "refill_amount": "5000000000000000000", + "refill_balance": "2000000000000000000", + "refill_interval": 300, + } + + for i := range numSpammers { + name := fmt.Sprintf("bench-gasburner-%d", i) + id, err := api.CreateSpammer(name, spamoor.ScenarioGasBurnerTX, gasburnerCfg, true) + s.Require().NoError(err, "failed to create spammer %s", name) + t.Cleanup(func() { _ = api.DeleteSpammer(id) }) + } + + // wait for wallet prep and contract deployment to finish before + // recording start block so warmup is excluded from the measurement. + pollSentTotal := func() (float64, error) { + metrics, mErr := api.GetMetrics() + if mErr != nil { + return 0, mErr + } + return sumCounter(metrics["spamoor_transactions_sent_total"]), nil + } + waitForMetricTarget(t, "spamoor_transactions_sent_total (warmup)", pollSentTotal, warmupTxs, waitTimeout) + + startHeader, err := e.ethClient.HeaderByNumber(ctx, nil) + s.Require().NoError(err, "failed to get start block header") + startBlock := startHeader.Number.Uint64() + loadStart := time.Now() + t.Logf("start block: %d (after warmup)", startBlock) + + // wait for all transactions to be sent + waitForMetricTarget(t, "spamoor_transactions_sent_total", pollSentTotal, float64(totalCount), waitTimeout) + + // wait for pending txs to drain + drainCtx, drainCancel := context.WithTimeout(ctx, 30*time.Second) + defer drainCancel() + waitForDrain(drainCtx, t.Logf, e.ethClient, 10) + wallClock := time.Since(loadStart) + + endHeader, err := e.ethClient.HeaderByNumber(ctx, nil) + s.Require().NoError(err, "failed to get end block header") + endBlock := endHeader.Number.Uint64() + t.Logf("end block: %d (range %d blocks)", endBlock, endBlock-startBlock) + + // collect block-level gas/tx metrics + bm, err := collectBlockMetrics(ctx, e.ethClient, startBlock, endBlock) + s.Require().NoError(err, "failed to collect block metrics") + + summary := bm.summarize() + s.Require().Greater(summary.SteadyState, time.Duration(0), "expected non-zero steady-state duration") + summary.log(t, startBlock, endBlock, bm.TotalBlockCount, bm.BlockCount, wallClock) + + // derive seconds_per_gigagas from the summary's MGas/s + var secsPerGigagas float64 + if summary.AchievedMGas > 0 { + // MGas/s -> Ggas/s = MGas/s / 1000, then invert + secsPerGigagas = 1000.0 / summary.AchievedMGas + } + t.Logf("seconds_per_gigagas: %.4f", secsPerGigagas) + + // collect and report traces + traces := s.collectTraces(e, serviceName) + + if overhead, ok := evNodeOverhead(traces.evNode); ok { + t.Logf("ev-node overhead: %.1f%%", overhead) + w.addEntry(entry{Name: "GasBurner - ev-node overhead", Unit: "%", Value: overhead}) + } + + w.addEntries(summary.entries("GasBurner")) + w.addSpans(traces.allSpans()) + w.addEntry(entry{ + Name: fmt.Sprintf("%s - seconds_per_gigagas", w.label), + Unit: "s/Ggas", + Value: secsPerGigagas, + }) +} diff --git a/test/e2e/benchmark/helpers.go b/test/e2e/benchmark/helpers.go index bdeaeb05b..74b3b4d20 100644 --- a/test/e2e/benchmark/helpers.go +++ b/test/e2e/benchmark/helpers.go @@ -268,6 +268,8 @@ type blockMetricsSummary struct { AvgTx float64 // BlocksPerSec is non-empty blocks / steady-state seconds. BlocksPerSec float64 + // AvgBlockInterval is the mean time between all consecutive blocks. + AvgBlockInterval time.Duration // NonEmptyRatio is (non-empty blocks / total blocks) * 100. NonEmptyRatio float64 } @@ -287,6 +289,15 @@ func (m *blockMetrics) summarize() *blockMetricsSummary { blocksPerSec = float64(m.BlockCount) / ss.Seconds() } + var avgBlockInterval time.Duration + if len(m.BlockIntervals) > 0 { + var total time.Duration + for _, d := range m.BlockIntervals { + total += d + } + avgBlockInterval = total / time.Duration(len(m.BlockIntervals)) + } + return &blockMetricsSummary{ SteadyState: ss, AchievedMGas: mgasPerSec(m.TotalGasUsed, ss), @@ -300,8 +311,9 @@ func (m *blockMetrics) summarize() *blockMetricsSummary { TxP99: txP99, AvgGas: m.avgGasPerBlock(), AvgTx: m.avgTxPerBlock(), - BlocksPerSec: blocksPerSec, - NonEmptyRatio: m.nonEmptyRatio(), + BlocksPerSec: blocksPerSec, + AvgBlockInterval: avgBlockInterval, + NonEmptyRatio: m.nonEmptyRatio(), } } @@ -312,8 +324,8 @@ func (m *blockMetrics) summarize() *blockMetricsSummary { func (s *blockMetricsSummary) log(t testing.TB, startBlock, endBlock uint64, totalBlocks, nonEmptyBlocks int, wallClock time.Duration) { t.Logf("block range: %d-%d (%d total, %d non-empty, %.1f%% non-empty)", startBlock, endBlock, totalBlocks, nonEmptyBlocks, s.NonEmptyRatio) - t.Logf("block intervals: p50=%s, p99=%s, max=%s", - s.IntervalP50.Round(time.Millisecond), s.IntervalP99.Round(time.Millisecond), s.IntervalMax.Round(time.Millisecond)) + t.Logf("block intervals: avg=%s, p50=%s, p99=%s, max=%s", + s.AvgBlockInterval.Round(time.Millisecond), s.IntervalP50.Round(time.Millisecond), s.IntervalP99.Round(time.Millisecond), s.IntervalMax.Round(time.Millisecond)) t.Logf("gas/block (non-empty): avg=%.0f, p50=%.0f, p99=%.0f", s.AvgGas, s.GasP50, s.GasP99) t.Logf("tx/block (non-empty): avg=%.1f, p50=%.0f, p99=%.0f", s.AvgTx, s.TxP50, s.TxP99) t.Logf("throughput: %.2f MGas/s, %.1f TPS over %s steady-state (%s wall clock)", @@ -332,6 +344,7 @@ func (s *blockMetricsSummary) entries(prefix string) []entry { {Name: prefix + " - avg tx/block", Unit: "count", Value: s.AvgTx}, {Name: prefix + " - blocks/s", Unit: "blocks/s", Value: s.BlocksPerSec}, {Name: prefix + " - non-empty block ratio", Unit: "%", Value: s.NonEmptyRatio}, + {Name: prefix + " - avg block interval", Unit: "ms", Value: float64(s.AvgBlockInterval.Milliseconds())}, {Name: prefix + " - block interval p50", Unit: "ms", Value: float64(s.IntervalP50.Milliseconds())}, {Name: prefix + " - block interval p99", Unit: "ms", Value: float64(s.IntervalP99.Milliseconds())}, {Name: prefix + " - gas/block p50", Unit: "gas", Value: s.GasP50}, @@ -368,6 +381,22 @@ func evNodeOverhead(spans []e2e.TraceSpan) (float64, bool) { return (produceAvg - executeAvg) / produceAvg * 100, true } +// waitForMetricTarget polls a metric getter function every 2s until the +// returned value >= target, or fails the test on timeout. +func waitForMetricTarget(t testing.TB, name string, poll func() (float64, error), target float64, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + v, err := poll() + if err == nil && v >= target { + t.Logf("metric %s reached %.0f (target %.0f)", name, v, target) + return + } + time.Sleep(2 * time.Second) + } + t.Fatalf("metric %s did not reach target %.0f within %v", name, target, timeout) +} + // collectBlockMetrics iterates all headers in [startBlock, endBlock] to collect // gas and transaction metrics. Empty blocks are skipped for gas/tx aggregation // but included in block interval tracking.