From ece26b51dde5390a27bb8a4e13acda1133682fc6 Mon Sep 17 00:00:00 2001 From: chatton Date: Wed, 25 Feb 2026 12:18:11 +0000 Subject: [PATCH] feat: add gasburner gas throughput benchmark - Add TestGasBurner to SpamoorSuite measuring seconds_per_gigagas - Add measureGasThroughput and waitForMetricTarget helpers - Add gasburner CI job with dashboard publishing --- .github/workflows/benchmark.yml | 51 ++++++++++++++- test/e2e/benchmark/gasburner_test.go | 94 ++++++++++++++++++++++++++++ test/e2e/benchmark/metrics.go | 70 +++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 test/e2e/benchmark/gasburner_test.go diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 2f8fcdb1c..12e5ea4c8 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -72,10 +72,35 @@ jobs: name: spamoor-benchmark-results path: test/e2e/benchmark/spamoor_bench.json + gasburner-benchmark: + name: Gasburner Trace Benchmark + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: ./go.mod + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + - name: Build binaries + run: make build-evm build-da + - name: Run Gasburner benchmark + run: | + cd test/e2e && BENCH_JSON_OUTPUT=benchmark/gasburner_bench.json go test -tags evm \ + -run='^TestSpamoorSuite$/^TestGasBurner$' -v -timeout=15m \ + --evm-binary=../../build/evm ./benchmark/ + - name: Upload benchmark results + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: gasburner-benchmark-results + path: test/e2e/benchmark/gasburner_bench.json + # single job to push all results to gh-pages sequentially, avoiding race conditions publish-benchmarks: name: Publish Benchmark Results - needs: [evm-benchmark, spamoor-benchmark] + needs: [evm-benchmark, spamoor-benchmark, gasburner-benchmark] runs-on: ubuntu-latest permissions: contents: write @@ -92,6 +117,11 @@ jobs: with: name: spamoor-benchmark-results path: test/e2e/benchmark/ + - name: Download Gasburner benchmark results + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: gasburner-benchmark-results + path: test/e2e/benchmark/ # only update the benchmark baseline on push/dispatch, not on PRs - name: Store EVM Contract Roundtrip result @@ -145,3 +175,22 @@ jobs: alert-threshold: '150%' fail-on-alert: false comment-on-alert: true + + # delete local gh-pages so the next benchmark action step fetches fresh from remote + - name: Reset local gh-pages branch + if: always() + run: git branch -D gh-pages || true + + - name: Store Gasburner Trace result + if: always() + uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7 + with: + name: Gasburner Trace Benchmarks + tool: 'customSmallerIsBetter' + output-file-path: test/e2e/benchmark/gasburner_bench.json + auto-push: ${{ github.event_name != 'pull_request' }} + save-data-file: ${{ github.event_name != 'pull_request' }} + github-token: ${{ secrets.GITHUB_TOKEN }} + alert-threshold: '150%' + fail-on-alert: false + comment-on-alert: true diff --git a/test/e2e/benchmark/gasburner_test.go b/test/e2e/benchmark/gasburner_test.go new file mode 100644 index 000000000..a9a06ba51 --- /dev/null +++ b/test/e2e/benchmark/gasburner_test.go @@ -0,0 +1,94 @@ +//go:build evm + +package benchmark + +import ( + "fmt" + "time" + + "github.com/celestiaorg/tastora/framework/docker/evstack/spamoor" + e2e "github.com/evstack/ev-node/test/e2e" +) + +// 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() { + t := s.T() + w := newResultWriter(t, "GasBurner") + defer w.flush() + + e := s.setupEnv(config{ + rethTag: "pr-142", + serviceName: "ev-node-gasburner", + }) + api := e.spamoorAPI + + const totalCount = 10000 + gasburnerCfg := map[string]any{ + "gas_units_to_burn": 3_000_000, + "total_count": totalCount, + "throughput": 1000, + "max_pending": 5000, + "max_wallets": 500, + "rebroadcast": 0, + "base_fee": 20, + "tip_fee": 5, + "refill_amount": "5000000000000000000", + "refill_balance": "2000000000000000000", + "refill_interval": 300, + } + + id, err := api.CreateSpammer("bench-gasburner", spamoor.ScenarioGasBurnerTX, gasburnerCfg, true) + s.Require().NoError(err, "failed to create gasburner spammer") + 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. + const warmupTxs = 50 + pollSentTotal := func() (float64, error) { + metrics, err := api.GetMetrics() + if err != nil { + return 0, err + } + return sumCounter(metrics["spamoor_transactions_sent_total"]), nil + } + waitForMetricTarget(t, "spamoor_transactions_sent_total (warmup)", pollSentTotal, warmupTxs, 5*time.Minute) + + startHeader, err := e.ethClient.HeaderByNumber(t.Context(), nil) + s.Require().NoError(err, "failed to get start block header") + startBlock := startHeader.Number.Uint64() + t.Logf("start block: %d (after wallet prep)", startBlock) + + waitForMetricTarget(t, "spamoor_transactions_sent_total", pollSentTotal, float64(totalCount), 5*time.Minute) + + endHeader, err := e.ethClient.HeaderByNumber(t.Context(), 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) + + gas := measureGasThroughput(t, t.Context(), e.ethClient, startBlock, endBlock) + + // collect traces + evNodeSpans := s.collectServiceTraces(e, "ev-node-gasburner") + evRethSpans := s.collectServiceTraces(e, "ev-reth") + e2e.PrintTraceReport(t, "ev-node-gasburner", evNodeSpans) + e2e.PrintTraceReport(t, "ev-reth", evRethSpans) + + // assert expected ev-reth spans + assertSpanNames(t, evRethSpans, []string{ + "build_payload", + "try_build", + "validate_transaction", + "validate_evnode", + "try_new", + "execute_tx", + }, "ev-reth") + + w.addSpans(append(evNodeSpans, evRethSpans...)) + w.addEntry(entry{ + Name: fmt.Sprintf("%s - seconds_per_gigagas", w.label), + Unit: "s/Ggas", + Value: 1.0 / gas.gigagasPerSec, + }) +} diff --git a/test/e2e/benchmark/metrics.go b/test/e2e/benchmark/metrics.go index fcc51af86..59a35ff5a 100644 --- a/test/e2e/benchmark/metrics.go +++ b/test/e2e/benchmark/metrics.go @@ -3,14 +3,84 @@ package benchmark import ( + "context" "fmt" + "math/big" "net/http" "testing" "time" + "github.com/ethereum/go-ethereum/ethclient" dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" ) +// gasThroughput holds the result of scanning a block range for gas usage. +type gasThroughput struct { + totalGas uint64 + gigagasPerSec float64 +} + +// measureGasThroughput scans blocks in [startBlock+1, endBlock] and computes +// gas throughput over the steady-state window (first to last non-empty block). +func measureGasThroughput(t testing.TB, ctx context.Context, client *ethclient.Client, startBlock, endBlock uint64) gasThroughput { + t.Helper() + + var firstGasBlock, lastGasBlock uint64 + var totalGas uint64 + var emptyBlocks, nonEmptyBlocks int + for i := startBlock + 1; i <= endBlock; i++ { + header, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(i)) + require.NoError(t, err, "failed to get header for block %d", i) + if header.GasUsed == 0 { + emptyBlocks++ + continue + } + nonEmptyBlocks++ + if firstGasBlock == 0 { + firstGasBlock = i + } + lastGasBlock = i + totalGas += header.GasUsed + } + t.Logf("block summary: %d empty, %d non-empty out of %d total", emptyBlocks, nonEmptyBlocks, endBlock-startBlock) + require.NotZero(t, firstGasBlock, "no blocks with gas found") + + firstGasHeader, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(firstGasBlock)) + require.NoError(t, err, "failed to get first gas block header") + lastGasHeader, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(lastGasBlock)) + require.NoError(t, err, "failed to get last gas block header") + + elapsed := time.Duration(lastGasHeader.Time-firstGasHeader.Time) * time.Second + if elapsed == 0 { + elapsed = 1 * time.Second + } + t.Logf("steady-state: blocks %d-%d, elapsed %v", firstGasBlock, lastGasBlock, elapsed) + + gigagas := float64(totalGas) / 1e9 + gigagasPerSec := float64(totalGas) / elapsed.Seconds() / 1e9 + t.Logf("total gas used: %d (%.2f gigagas)", totalGas, gigagas) + t.Logf("gas throughput: %.2f gigagas/sec", gigagasPerSec) + + return gasThroughput{totalGas: totalGas, gigagasPerSec: gigagasPerSec} +} + +// 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) +} + // requireHTTP polls a URL until it returns a 2xx status code or the timeout expires. func requireHTTP(t testing.TB, url string, timeout time.Duration) { t.Helper()