Skip to content

Commit ece26b5

Browse files
committed
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
1 parent 81dc810 commit ece26b5

File tree

3 files changed

+214
-1
lines changed

3 files changed

+214
-1
lines changed

.github/workflows/benchmark.yml

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,35 @@ jobs:
7272
name: spamoor-benchmark-results
7373
path: test/e2e/benchmark/spamoor_bench.json
7474

75+
gasburner-benchmark:
76+
name: Gasburner Trace Benchmark
77+
runs-on: ubuntu-latest
78+
timeout-minutes: 30
79+
steps:
80+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
81+
- name: Set up Go
82+
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
83+
with:
84+
go-version-file: ./go.mod
85+
- name: Set up Docker Buildx
86+
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
87+
- name: Build binaries
88+
run: make build-evm build-da
89+
- name: Run Gasburner benchmark
90+
run: |
91+
cd test/e2e && BENCH_JSON_OUTPUT=benchmark/gasburner_bench.json go test -tags evm \
92+
-run='^TestSpamoorSuite$/^TestGasBurner$' -v -timeout=15m \
93+
--evm-binary=../../build/evm ./benchmark/
94+
- name: Upload benchmark results
95+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
96+
with:
97+
name: gasburner-benchmark-results
98+
path: test/e2e/benchmark/gasburner_bench.json
99+
75100
# single job to push all results to gh-pages sequentially, avoiding race conditions
76101
publish-benchmarks:
77102
name: Publish Benchmark Results
78-
needs: [evm-benchmark, spamoor-benchmark]
103+
needs: [evm-benchmark, spamoor-benchmark, gasburner-benchmark]
79104
runs-on: ubuntu-latest
80105
permissions:
81106
contents: write
@@ -92,6 +117,11 @@ jobs:
92117
with:
93118
name: spamoor-benchmark-results
94119
path: test/e2e/benchmark/
120+
- name: Download Gasburner benchmark results
121+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
122+
with:
123+
name: gasburner-benchmark-results
124+
path: test/e2e/benchmark/
95125

96126
# only update the benchmark baseline on push/dispatch, not on PRs
97127
- name: Store EVM Contract Roundtrip result
@@ -145,3 +175,22 @@ jobs:
145175
alert-threshold: '150%'
146176
fail-on-alert: false
147177
comment-on-alert: true
178+
179+
# delete local gh-pages so the next benchmark action step fetches fresh from remote
180+
- name: Reset local gh-pages branch
181+
if: always()
182+
run: git branch -D gh-pages || true
183+
184+
- name: Store Gasburner Trace result
185+
if: always()
186+
uses: benchmark-action/github-action-benchmark@4bdcce38c94cec68da58d012ac24b7b1155efe8b # v1.20.7
187+
with:
188+
name: Gasburner Trace Benchmarks
189+
tool: 'customSmallerIsBetter'
190+
output-file-path: test/e2e/benchmark/gasburner_bench.json
191+
auto-push: ${{ github.event_name != 'pull_request' }}
192+
save-data-file: ${{ github.event_name != 'pull_request' }}
193+
github-token: ${{ secrets.GITHUB_TOKEN }}
194+
alert-threshold: '150%'
195+
fail-on-alert: false
196+
comment-on-alert: true
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//go:build evm
2+
3+
package benchmark
4+
5+
import (
6+
"fmt"
7+
"time"
8+
9+
"github.com/celestiaorg/tastora/framework/docker/evstack/spamoor"
10+
e2e "github.com/evstack/ev-node/test/e2e"
11+
)
12+
13+
// TestGasBurner measures gas throughput using a deterministic gasburner
14+
// workload. The result is tracked via BENCH_JSON_OUTPUT as seconds_per_gigagas
15+
// (lower is better) on the benchmark dashboard.
16+
func (s *SpamoorSuite) TestGasBurner() {
17+
t := s.T()
18+
w := newResultWriter(t, "GasBurner")
19+
defer w.flush()
20+
21+
e := s.setupEnv(config{
22+
rethTag: "pr-142",
23+
serviceName: "ev-node-gasburner",
24+
})
25+
api := e.spamoorAPI
26+
27+
const totalCount = 10000
28+
gasburnerCfg := map[string]any{
29+
"gas_units_to_burn": 3_000_000,
30+
"total_count": totalCount,
31+
"throughput": 1000,
32+
"max_pending": 5000,
33+
"max_wallets": 500,
34+
"rebroadcast": 0,
35+
"base_fee": 20,
36+
"tip_fee": 5,
37+
"refill_amount": "5000000000000000000",
38+
"refill_balance": "2000000000000000000",
39+
"refill_interval": 300,
40+
}
41+
42+
id, err := api.CreateSpammer("bench-gasburner", spamoor.ScenarioGasBurnerTX, gasburnerCfg, true)
43+
s.Require().NoError(err, "failed to create gasburner spammer")
44+
t.Cleanup(func() { _ = api.DeleteSpammer(id) })
45+
46+
// wait for wallet prep and contract deployment to finish before
47+
// recording start block so warmup is excluded from the measurement.
48+
const warmupTxs = 50
49+
pollSentTotal := func() (float64, error) {
50+
metrics, err := api.GetMetrics()
51+
if err != nil {
52+
return 0, err
53+
}
54+
return sumCounter(metrics["spamoor_transactions_sent_total"]), nil
55+
}
56+
waitForMetricTarget(t, "spamoor_transactions_sent_total (warmup)", pollSentTotal, warmupTxs, 5*time.Minute)
57+
58+
startHeader, err := e.ethClient.HeaderByNumber(t.Context(), nil)
59+
s.Require().NoError(err, "failed to get start block header")
60+
startBlock := startHeader.Number.Uint64()
61+
t.Logf("start block: %d (after wallet prep)", startBlock)
62+
63+
waitForMetricTarget(t, "spamoor_transactions_sent_total", pollSentTotal, float64(totalCount), 5*time.Minute)
64+
65+
endHeader, err := e.ethClient.HeaderByNumber(t.Context(), nil)
66+
s.Require().NoError(err, "failed to get end block header")
67+
endBlock := endHeader.Number.Uint64()
68+
t.Logf("end block: %d (range %d blocks)", endBlock, endBlock-startBlock)
69+
70+
gas := measureGasThroughput(t, t.Context(), e.ethClient, startBlock, endBlock)
71+
72+
// collect traces
73+
evNodeSpans := s.collectServiceTraces(e, "ev-node-gasburner")
74+
evRethSpans := s.collectServiceTraces(e, "ev-reth")
75+
e2e.PrintTraceReport(t, "ev-node-gasburner", evNodeSpans)
76+
e2e.PrintTraceReport(t, "ev-reth", evRethSpans)
77+
78+
// assert expected ev-reth spans
79+
assertSpanNames(t, evRethSpans, []string{
80+
"build_payload",
81+
"try_build",
82+
"validate_transaction",
83+
"validate_evnode",
84+
"try_new",
85+
"execute_tx",
86+
}, "ev-reth")
87+
88+
w.addSpans(append(evNodeSpans, evRethSpans...))
89+
w.addEntry(entry{
90+
Name: fmt.Sprintf("%s - seconds_per_gigagas", w.label),
91+
Unit: "s/Ggas",
92+
Value: 1.0 / gas.gigagasPerSec,
93+
})
94+
}

test/e2e/benchmark/metrics.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,84 @@
33
package benchmark
44

55
import (
6+
"context"
67
"fmt"
8+
"math/big"
79
"net/http"
810
"testing"
911
"time"
1012

13+
"github.com/ethereum/go-ethereum/ethclient"
1114
dto "github.com/prometheus/client_model/go"
15+
"github.com/stretchr/testify/require"
1216
)
1317

18+
// gasThroughput holds the result of scanning a block range for gas usage.
19+
type gasThroughput struct {
20+
totalGas uint64
21+
gigagasPerSec float64
22+
}
23+
24+
// measureGasThroughput scans blocks in [startBlock+1, endBlock] and computes
25+
// gas throughput over the steady-state window (first to last non-empty block).
26+
func measureGasThroughput(t testing.TB, ctx context.Context, client *ethclient.Client, startBlock, endBlock uint64) gasThroughput {
27+
t.Helper()
28+
29+
var firstGasBlock, lastGasBlock uint64
30+
var totalGas uint64
31+
var emptyBlocks, nonEmptyBlocks int
32+
for i := startBlock + 1; i <= endBlock; i++ {
33+
header, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(i))
34+
require.NoError(t, err, "failed to get header for block %d", i)
35+
if header.GasUsed == 0 {
36+
emptyBlocks++
37+
continue
38+
}
39+
nonEmptyBlocks++
40+
if firstGasBlock == 0 {
41+
firstGasBlock = i
42+
}
43+
lastGasBlock = i
44+
totalGas += header.GasUsed
45+
}
46+
t.Logf("block summary: %d empty, %d non-empty out of %d total", emptyBlocks, nonEmptyBlocks, endBlock-startBlock)
47+
require.NotZero(t, firstGasBlock, "no blocks with gas found")
48+
49+
firstGasHeader, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(firstGasBlock))
50+
require.NoError(t, err, "failed to get first gas block header")
51+
lastGasHeader, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(lastGasBlock))
52+
require.NoError(t, err, "failed to get last gas block header")
53+
54+
elapsed := time.Duration(lastGasHeader.Time-firstGasHeader.Time) * time.Second
55+
if elapsed == 0 {
56+
elapsed = 1 * time.Second
57+
}
58+
t.Logf("steady-state: blocks %d-%d, elapsed %v", firstGasBlock, lastGasBlock, elapsed)
59+
60+
gigagas := float64(totalGas) / 1e9
61+
gigagasPerSec := float64(totalGas) / elapsed.Seconds() / 1e9
62+
t.Logf("total gas used: %d (%.2f gigagas)", totalGas, gigagas)
63+
t.Logf("gas throughput: %.2f gigagas/sec", gigagasPerSec)
64+
65+
return gasThroughput{totalGas: totalGas, gigagasPerSec: gigagasPerSec}
66+
}
67+
68+
// waitForMetricTarget polls a metric getter function every 2s until the returned
69+
// value >= target, or fails the test on timeout.
70+
func waitForMetricTarget(t testing.TB, name string, poll func() (float64, error), target float64, timeout time.Duration) {
71+
t.Helper()
72+
deadline := time.Now().Add(timeout)
73+
for time.Now().Before(deadline) {
74+
v, err := poll()
75+
if err == nil && v >= target {
76+
t.Logf("metric %s reached %.0f (target %.0f)", name, v, target)
77+
return
78+
}
79+
time.Sleep(2 * time.Second)
80+
}
81+
t.Fatalf("metric %s did not reach target %.0f within %v", name, target, timeout)
82+
}
83+
1484
// requireHTTP polls a URL until it returns a 2xx status code or the timeout expires.
1585
func requireHTTP(t testing.TB, url string, timeout time.Duration) {
1686
t.Helper()

0 commit comments

Comments
 (0)