Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
94 changes: 94 additions & 0 deletions test/e2e/benchmark/gasburner_test.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
70 changes: 70 additions & 0 deletions test/e2e/benchmark/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading