From 9dae839578fb34bff3061569a7e8fa8a6709fa97 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 29 Jun 2026 20:36:16 +0800 Subject: [PATCH 01/34] add evmonly executor load test harness --- giga/evmonly/README.md | 2 + giga/evmonly/cmd/evmonly-loadtest/README.md | 80 ++ giga/evmonly/cmd/evmonly-loadtest/main.go | 808 ++++++++++++++++++ .../evmonly/cmd/evmonly-loadtest/main_test.go | 71 ++ 4 files changed, 961 insertions(+) create mode 100644 giga/evmonly/cmd/evmonly-loadtest/README.md create mode 100644 giga/evmonly/cmd/evmonly-loadtest/main.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/main_test.go diff --git a/giga/evmonly/README.md b/giga/evmonly/README.md index 828dc13035..756d052006 100644 --- a/giga/evmonly/README.md +++ b/giga/evmonly/README.md @@ -34,6 +34,8 @@ The `evmonly` package currently provides: contract address, and effective gas price - a map-backed `MemoryState` for tests and early integration - fail-closed custom precompile placeholders +- a standalone load harness at `giga/evmonly/cmd/evmonly-loadtest` that feeds + generated transfer blocks into the executor with mock state and receipt sinks The executor accepts config for nonce checks, gas-price checks, minimum gas price, chain config, parse workers, OCC workers, result pooling, and the custom diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md new file mode 100644 index 0000000000..fc3c4a0186 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -0,0 +1,80 @@ +# evmonly-loadtest + +`evmonly-loadtest` is a standalone executable for feeding synthetic blocks to +the EVM-only executor without Cosmos SDK state, mempool, RPC, or persistence. + +It currently generates pure EVM legacy transfer transactions. Each generated +sender account has exactly one nonce-0 transaction and is funded in the +command's in-memory genesis state before its block is queued. Recipients are +unique by default so the transfer workload can be extended toward +less-conflicting account layouts; pass `--recipient=0x...` to force a single +recipient. + +Run a bounded test: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest --blocks=1000 --txs-per-block=1000 +``` + +Run continuously until interrupted: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest --txs-per-block=1000 +``` + +Example local saturation run: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --blocks=2000 \ + --txs-per-block=1000 \ + --builders=16 \ + --workers=1 \ + --executor-workers=12 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 \ + --queue-size=512 +``` + +The zero gas price/min-gas settings keep the transfer workload focused on the +optimistic no-overlap case. Non-zero fees make every transaction update the +same coinbase balance, which is a real intra-block conflict. + +Useful knobs: + +- `--workers`: parallel executor workers. The default is `1`. +- `--executor-workers`: parallel OCC workers inside each executor. The default + is `min(12, GOMAXPROCS)`, following the `sei-v3` OCC worker default. +- `--builders`: parallel block builders used to keep the input queue full. The + default is `GOMAXPROCS`. +- `--queue-size`: buffered blocks ready for workers. The default is `64`. +- `--target-blocks-per-sec`: cap block input rate. The default `0` feeds as + fast as block generation and the queue allow. +- `--metrics-addr`: Prometheus endpoint. The default is + `127.0.0.1:9698`; set it to empty to disable HTTP metrics. +- `--report-interval`: stdout rate reporting interval. The default is `5s`. +- `--gas-price-wei`, `--min-gas-price-wei`, `--sender-balance-wei`, + `--transfer-value-wei`: transaction economics for the generated accounts. + +The command reports these saturation signals on stdout and at `/metrics`: + +- block input throughput +- block finishing throughput +- finished transactions per second +- total gas consumed per second +- queued blocks and cumulative totals + +The executor output is intentionally discarded through mocks: + +- `generatedState` implements `evmonly.StateReader` and supplies generated + genesis balances, nonces, code, and storage. +- `discardStateWriter` implements `evmonly.StateWriter` and sinks the + executor `StateChangeSet`. +- `discardReceiptSink` sinks Ethereum receipts. + +Future workloads should add another workload builder beside `transferWorkload`. +ERC20 transfers can reuse the same harness by adding contract code/storage to +`generatedState`, generating calldata transactions, and keeping the same block +producer/executor/metrics pipeline. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go new file mode 100644 index 0000000000..235ea79cab --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -0,0 +1,808 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "encoding/binary" + "errors" + "flag" + "fmt" + "math" + "math/big" + "net" + "net/http" + "os" + "os/signal" + "runtime" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "golang.org/x/sync/errgroup" + "golang.org/x/time/rate" + + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +const ( + defaultChainID = "713715" + defaultGasPriceWei = "1000000000" + defaultMinGasPriceWei = "1000000000" + defaultSenderBalance = "1000000000000000000" + defaultTransferValue = "1" + defaultMetricsAddr = "127.0.0.1:9698" + defaultReportInterval = 5 * time.Second + defaultQueueSize = 64 + defaultTxGasLimit = 21_000 + defaultTxsPerBlock = 1_000 + defaultWorkerCount = 1 + defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" +) + +type config struct { + blocks uint64 + txsPerBlock int + queueSize int + builders int + workers int + executorWorkers int + targetBlocksPerSec float64 + reportInterval time.Duration + metricsAddr string + workload string + chainID *big.Int + gasPrice *big.Int + minGasPrice *big.Int + senderBalance *big.Int + transferValue *big.Int + txGasLimit uint64 + blockGasLimit uint64 + coinbase common.Address + fixedRecipient *common.Address + disableGasPriceRule bool +} + +type blockEnvelope struct { + number uint64 + request evmonly.BlockRequest +} + +func main() { + cfg, err := parseConfig(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: %v\n", err) + os.Exit(2) + } + if err := run(cfg); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: %v\n", err) + os.Exit(1) + } +} + +func parseConfig(args []string) (config, error) { + cfg := config{} + fs := flag.NewFlagSet("evmonly-loadtest", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + chainID := fs.String("chain-id", defaultChainID, "EVM chain ID used to sign and execute transactions") + gasPrice := fs.String("gas-price-wei", defaultGasPriceWei, "legacy transaction gas price in wei") + minGasPrice := fs.String("min-gas-price-wei", defaultMinGasPriceWei, "executor minimum gas price in wei") + senderBalance := fs.String("sender-balance-wei", defaultSenderBalance, "generated sender genesis balance in wei") + transferValue := fs.String("transfer-value-wei", defaultTransferValue, "wei transferred by each generated transaction") + coinbase := fs.String("coinbase", defaultCoinbaseAddress, "block coinbase address") + recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") + + fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to feed; 0 runs until interrupted") + fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") + fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") + fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") + fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") + fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") + fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") + fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") + fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") + fs.StringVar(&cfg.workload, "workload", "transfer", "workload type; currently transfer") + fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") + fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") + fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") + + if err := fs.Parse(args); err != nil { + return config{}, err + } + var err error + if cfg.chainID, err = parsePositiveBig("chain-id", *chainID); err != nil { + return config{}, err + } + if cfg.gasPrice, err = parseNonNegativeBig("gas-price-wei", *gasPrice); err != nil { + return config{}, err + } + if cfg.minGasPrice, err = parseNonNegativeBig("min-gas-price-wei", *minGasPrice); err != nil { + return config{}, err + } + if cfg.senderBalance, err = parseNonNegativeBig("sender-balance-wei", *senderBalance); err != nil { + return config{}, err + } + if cfg.transferValue, err = parseNonNegativeBig("transfer-value-wei", *transferValue); err != nil { + return config{}, err + } + if !common.IsHexAddress(*coinbase) { + return config{}, fmt.Errorf("coinbase must be a hex EVM address") + } + cfg.coinbase = common.HexToAddress(*coinbase) + if *recipient != "" { + if !common.IsHexAddress(*recipient) { + return config{}, fmt.Errorf("recipient must be a hex EVM address") + } + addr := common.HexToAddress(*recipient) + cfg.fixedRecipient = &addr + } + cfg.workload = strings.ToLower(strings.TrimSpace(cfg.workload)) + if cfg.workload != "transfer" { + return config{}, fmt.Errorf("unsupported workload %q", cfg.workload) + } + if cfg.txsPerBlock <= 0 { + return config{}, fmt.Errorf("txs-per-block must be positive") + } + if cfg.queueSize <= 0 { + return config{}, fmt.Errorf("queue-size must be positive") + } + if cfg.builders <= 0 { + return config{}, fmt.Errorf("builders must be positive") + } + if cfg.workers <= 0 { + return config{}, fmt.Errorf("workers must be positive") + } + if cfg.executorWorkers <= 0 { + return config{}, fmt.Errorf("executor-workers must be positive") + } + if cfg.targetBlocksPerSec < 0 { + return config{}, fmt.Errorf("target-blocks-per-sec must be non-negative") + } + if cfg.reportInterval < 0 { + return config{}, fmt.Errorf("report-interval must be non-negative") + } + if cfg.txGasLimit == 0 { + return config{}, fmt.Errorf("tx-gas-limit must be positive") + } + if !cfg.disableGasPriceRule && cfg.gasPrice.Cmp(cfg.minGasPrice) < 0 { + return config{}, fmt.Errorf("gas-price-wei must be greater than or equal to min-gas-price-wei unless disable-gas-price-rule is set") + } + requiredBalance := new(big.Int).Mul(new(big.Int).SetUint64(cfg.txGasLimit), cfg.gasPrice) + requiredBalance.Add(requiredBalance, cfg.transferValue) + if cfg.senderBalance.Cmp(requiredBalance) < 0 { + return config{}, fmt.Errorf("sender-balance-wei must cover transfer value plus max gas cost: need at least %s", requiredBalance.String()) + } + return cfg, nil +} + +func defaultExecutorWorkers() int { + workers := runtime.GOMAXPROCS(0) + if workers > 12 { + return 12 + } + return workers +} + +func parsePositiveBig(name, raw string) (*big.Int, error) { + v, err := parseBig(name, raw) + if err != nil { + return nil, err + } + if v.Sign() <= 0 { + return nil, fmt.Errorf("%s must be positive", name) + } + return v, nil +} + +func parseNonNegativeBig(name, raw string) (*big.Int, error) { + v, err := parseBig(name, raw) + if err != nil { + return nil, err + } + if v.Sign() < 0 { + return nil, fmt.Errorf("%s must be non-negative", name) + } + return v, nil +} + +func parseBig(name, raw string) (*big.Int, error) { + v, ok := new(big.Int).SetString(strings.TrimSpace(raw), 10) + if !ok { + return nil, fmt.Errorf("%s must be a base-10 integer", name) + } + return v, nil +} + +func run(cfg config) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + state := newGeneratedState() + workload := newTransferWorkload(cfg, state) + registry := prometheus.NewRegistry() + metrics := newLoadMetrics(registry) + blocks := make(chan blockEnvelope, cfg.queueSize) + + var server *metricsServer + if cfg.metricsAddr != "" { + var err error + server, err = startMetricsServer(cfg.metricsAddr, registry) + if err != nil { + return err + } + defer func() { + if err := server.stop(3 * time.Second); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: metrics server shutdown: %v\n", err) + } + }() + fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) + } + + reportCtx, stopReporter := context.WithCancel(ctx) + reportDone := make(chan struct{}) + go func() { + defer close(reportDone) + reportLoop(reportCtx, cfg.reportInterval, metrics, blocks) + }() + + startedAt := time.Now() + group, groupCtx := errgroup.WithContext(ctx) + group.Go(func() error { + defer close(blocks) + return produceBlocks(groupCtx, cfg, workload, blocks, metrics) + }) + for workerID := 0; workerID < cfg.workers; workerID++ { + workerID := workerID + group.Go(func() error { + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: new(big.Int).Set(cfg.minGasPrice), + DisableGasPriceCheck: cfg.disableGasPriceRule, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state)) + return executeBlocks(groupCtx, workerID, executor, blocks, &discardStateWriter{}, discardReceiptSink{}, metrics) + }) + } + + err := group.Wait() + stopReporter() + <-reportDone + + if errors.Is(err, context.Canceled) { + err = nil + } + printFinalReport(startedAt, metrics.snapshot()) + return err +} + +func produceBlocks(ctx context.Context, cfg config, workload *transferWorkload, out chan<- blockEnvelope, metrics *loadMetrics) error { + var limiter *rate.Limiter + if cfg.targetBlocksPerSec > 0 { + burst := int(math.Ceil(cfg.targetBlocksPerSec)) + if burst < 1 { + burst = 1 + } + limiter = rate.NewLimiter(rate.Limit(cfg.targetBlocksPerSec), burst) + } + + var nextBlock atomic.Uint64 + group, groupCtx := errgroup.WithContext(ctx) + for builderID := 0; builderID < cfg.builders; builderID++ { + group.Go(func() error { + for { + number := nextBlock.Add(1) + if cfg.blocks != 0 && number > cfg.blocks { + return nil + } + if limiter != nil { + if err := limiter.Wait(groupCtx); err != nil { + return nil + } + } + request, err := workload.buildBlock(groupCtx, number) + if err != nil { + if groupCtx.Err() != nil { + return nil + } + return err + } + block := blockEnvelope{number: number, request: request} + select { + case out <- block: + metrics.recordInput() + case <-groupCtx.Done(): + return nil + } + } + }) + } + return group.Wait() +} + +func executeBlocks( + ctx context.Context, + workerID int, + executor evmonly.BlockExecutor, + blocks <-chan blockEnvelope, + stateWriter evmonly.StateWriter, + receiptSink receiptSink, + metrics *loadMetrics, +) error { + for { + select { + case <-ctx.Done(): + return nil + case block, ok := <-blocks: + if !ok { + return nil + } + result, err := executor.ExecuteBlock(ctx, block.request) + if err != nil { + metrics.recordExecutionError() + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) + } + stateWriter.ApplyChangeSet(result.ChangeSet) + receiptSink.StoreReceipts(block.number, result.Receipts) + metrics.recordFinished(len(result.Txs), result.GasUsed) + } + } +} + +type transferWorkload struct { + cfg config + state *generatedState + signer ethtypes.Signer + accountCursor atomic.Uint64 +} + +func newTransferWorkload(cfg config, state *generatedState) *transferWorkload { + return &transferWorkload{ + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.chainID), + } +} + +func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.txsPerBlock) + for i := range txs { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + raw, sender, err := w.buildTransferTx(accountIndex) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.state.SetBalance(sender, w.cfg.senderBalance) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: w.blockContext(number), + Txs: txs, + }, nil +} + +func (w *transferWorkload) buildTransferTx(accountIndex uint64) ([]byte, common.Address, error) { + key, err := deterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + recipient := w.recipient(accountIndex) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: new(big.Int).Set(w.cfg.gasPrice), + Gas: w.cfg.txGasLimit, + To: &recipient, + Value: new(big.Int).Set(w.cfg.transferValue), + }) + signed, err := ethtypes.SignTx(tx, w.signer, key) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} + +func (w *transferWorkload) recipient(accountIndex uint64) common.Address { + if w.cfg.fixedRecipient != nil { + return *w.cfg.fixedRecipient + } + return addressFromSeed("sei-evmonly-loadtest-recipient", accountIndex) +} + +func (w *transferWorkload) blockContext(number uint64) evmonly.BlockContext { + return evmonly.BlockContext{ + Number: number, + Time: uint64(time.Now().Unix()), + GasLimit: w.cfg.blockGasLimit, + ChainID: new(big.Int).Set(w.cfg.chainID), + BaseFee: big.NewInt(0), + Coinbase: w.cfg.coinbase, + ParentHash: hashFromSeed("sei-evmonly-loadtest-parent", number-1), + BlockHash: hashFromSeed("sei-evmonly-loadtest-block", number), + PrevRandao: hashFromSeed("sei-evmonly-loadtest-randao", number), + } +} + +func deterministicPrivateKey(index uint64) (*ecdsa.PrivateKey, error) { + var buf [16]byte + binary.BigEndian.PutUint64(buf[:8], index) + for attempt := uint64(0); ; attempt++ { + binary.BigEndian.PutUint64(buf[8:], attempt) + key, err := crypto.ToECDSA(crypto.Keccak256([]byte("sei-evmonly-loadtest-sender"), buf[:])) + if err == nil { + return key, nil + } + if attempt == ^uint64(0) { + break + } + } + return nil, fmt.Errorf("could not derive private key for account %d", index) +} + +func addressFromSeed(prefix string, index uint64) common.Address { + hash := hashFromSeed(prefix, index) + return common.BytesToAddress(hash[12:]) +} + +func hashFromSeed(prefix string, index uint64) common.Hash { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], index) + return crypto.Keccak256Hash([]byte(prefix), buf[:]) +} + +type generatedState struct { + mu sync.RWMutex + balances map[common.Address]*big.Int + nonces map[common.Address]uint64 + code map[common.Address][]byte + storage map[common.Address]map[common.Hash]common.Hash +} + +var _ evmonly.StateReader = (*generatedState)(nil) + +func newGeneratedState() *generatedState { + return &generatedState{ + balances: map[common.Address]*big.Int{}, + nonces: map[common.Address]uint64{}, + code: map[common.Address][]byte{}, + storage: map[common.Address]map[common.Hash]common.Hash{}, + } +} + +func (s *generatedState) GetBalance(addr common.Address) *big.Int { + s.mu.RLock() + defer s.mu.RUnlock() + if balance, ok := s.balances[addr]; ok && balance != nil { + return new(big.Int).Set(balance) + } + return new(big.Int) +} + +func (s *generatedState) SetBalance(addr common.Address, balance *big.Int) { + s.mu.Lock() + defer s.mu.Unlock() + if balance == nil { + s.balances[addr] = new(big.Int) + return + } + s.balances[addr] = new(big.Int).Set(balance) +} + +func (s *generatedState) GetNonce(addr common.Address) uint64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.nonces[addr] +} + +func (s *generatedState) SetNonce(addr common.Address, nonce uint64) { + s.mu.Lock() + defer s.mu.Unlock() + s.nonces[addr] = nonce +} + +func (s *generatedState) GetCode(addr common.Address) []byte { + s.mu.RLock() + defer s.mu.RUnlock() + return cloneBytes(s.code[addr]) +} + +func (s *generatedState) SetCode(addr common.Address, code []byte) { + s.mu.Lock() + defer s.mu.Unlock() + s.code[addr] = cloneBytes(code) +} + +func (s *generatedState) GetState(addr common.Address, key common.Hash) common.Hash { + s.mu.RLock() + defer s.mu.RUnlock() + if accountStorage, ok := s.storage[addr]; ok { + return accountStorage[key] + } + return common.Hash{} +} + +func (s *generatedState) SetState(addr common.Address, key common.Hash, value common.Hash) { + s.mu.Lock() + defer s.mu.Unlock() + accountStorage, ok := s.storage[addr] + if !ok { + accountStorage = map[common.Hash]common.Hash{} + s.storage[addr] = accountStorage + } + if value == (common.Hash{}) { + delete(accountStorage, key) + return + } + accountStorage[key] = value +} + +func cloneBytes(v []byte) []byte { + if len(v) == 0 { + return nil + } + return append([]byte(nil), v...) +} + +type discardStateWriter struct{} + +var _ evmonly.StateWriter = (*discardStateWriter)(nil) + +func (*discardStateWriter) ApplyChangeSet(evmonly.StateChangeSet) {} + +type receiptSink interface { + StoreReceipts(height uint64, receipts ethtypes.Receipts) +} + +type discardReceiptSink struct{} + +func (discardReceiptSink) StoreReceipts(uint64, ethtypes.Receipts) {} + +type loadMetrics struct { + inputBlocks atomic.Uint64 + finishedBlocks atomic.Uint64 + finishedTxs atomic.Uint64 + gasConsumed atomic.Uint64 + executionErrors atomic.Uint64 + + inputBlocksTotal prometheus.Counter + finishedBlocksTotal prometheus.Counter + finishedTxsTotal prometheus.Counter + gasConsumedTotal prometheus.Counter + executionErrorsTotal prometheus.Counter + + inputBlockRate prometheus.Gauge + finishedBlockRate prometheus.Gauge + txRate prometheus.Gauge + gasRate prometheus.Gauge + queuedBlocks prometheus.Gauge +} + +type metricsSnapshot struct { + at time.Time + inputBlocks uint64 + finishedBlocks uint64 + finishedTxs uint64 + gasConsumed uint64 + executionErrors uint64 +} + +func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { + m := &loadMetrics{ + inputBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_input_total", + Help: "Total blocks fed to the EVM-only executor input queue.", + }), + finishedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_finished_total", + Help: "Total blocks that finished EVM-only executor execution.", + }), + finishedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_finished_total", + Help: "Total transactions that finished EVM-only executor execution.", + }), + gasConsumedTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_gas_consumed_total", + Help: "Total EVM gas consumed by finished blocks.", + }), + executionErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_execution_errors_total", + Help: "Total block execution errors returned by the EVM-only executor.", + }), + inputBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_input_throughput", + Help: "Most recent measured block input throughput in blocks per second.", + }), + finishedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_finished_throughput", + Help: "Most recent measured block completion throughput in blocks per second.", + }), + txRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_per_second", + Help: "Most recent measured transaction execution throughput in transactions per second.", + }), + gasRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_gas_consumed_per_second", + Help: "Most recent measured gas consumption throughput in gas per second.", + }), + queuedBlocks: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_queued_blocks", + Help: "Blocks currently waiting in the executor input queue.", + }), + } + registry.MustRegister( + m.inputBlocksTotal, + m.finishedBlocksTotal, + m.finishedTxsTotal, + m.gasConsumedTotal, + m.executionErrorsTotal, + m.inputBlockRate, + m.finishedBlockRate, + m.txRate, + m.gasRate, + m.queuedBlocks, + ) + return m +} + +func (m *loadMetrics) recordInput() { + m.inputBlocks.Add(1) + m.inputBlocksTotal.Inc() +} + +func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64) { + m.finishedBlocks.Add(1) + m.finishedTxs.Add(uint64(txCount)) + m.gasConsumed.Add(gasUsed) + m.finishedBlocksTotal.Inc() + m.finishedTxsTotal.Add(float64(txCount)) + m.gasConsumedTotal.Add(float64(gasUsed)) +} + +func (m *loadMetrics) recordExecutionError() { + m.executionErrors.Add(1) + m.executionErrorsTotal.Inc() +} + +func (m *loadMetrics) snapshot() metricsSnapshot { + return metricsSnapshot{ + at: time.Now(), + inputBlocks: m.inputBlocks.Load(), + finishedBlocks: m.finishedBlocks.Load(), + finishedTxs: m.finishedTxs.Load(), + gasConsumed: m.gasConsumed.Load(), + executionErrors: m.executionErrors.Load(), + } +} + +func (m *loadMetrics) setRates(prev, curr metricsSnapshot, queued int) { + elapsed := curr.at.Sub(prev.at).Seconds() + if elapsed <= 0 { + return + } + inputRate := float64(curr.inputBlocks-prev.inputBlocks) / elapsed + finishedRate := float64(curr.finishedBlocks-prev.finishedBlocks) / elapsed + txRate := float64(curr.finishedTxs-prev.finishedTxs) / elapsed + gasRate := float64(curr.gasConsumed-prev.gasConsumed) / elapsed + m.inputBlockRate.Set(inputRate) + m.finishedBlockRate.Set(finishedRate) + m.txRate.Set(txRate) + m.gasRate.Set(gasRate) + m.queuedBlocks.Set(float64(queued)) +} + +func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetrics, blocks <-chan blockEnvelope) { + if interval == 0 { + <-ctx.Done() + return + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + prev := metrics.snapshot() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + curr := metrics.snapshot() + queued := len(blocks) + metrics.setRates(prev, curr, queued) + elapsed := curr.at.Sub(prev.at).Seconds() + if elapsed <= 0 { + prev = curr + continue + } + fmt.Printf( + "input_blocks/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d totals(input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d)\n", + float64(curr.inputBlocks-prev.inputBlocks)/elapsed, + float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, + float64(curr.finishedTxs-prev.finishedTxs)/elapsed, + float64(curr.gasConsumed-prev.gasConsumed)/elapsed, + queued, + curr.inputBlocks, + curr.finishedBlocks, + curr.finishedTxs, + curr.gasConsumed, + curr.executionErrors, + ) + prev = curr + } + } +} + +func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { + elapsed := snapshot.at.Sub(startedAt).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + fmt.Printf( + "complete elapsed=%s input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d avg_input_blocks/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + snapshot.at.Sub(startedAt).Round(time.Millisecond), + snapshot.inputBlocks, + snapshot.finishedBlocks, + snapshot.finishedTxs, + snapshot.gasConsumed, + snapshot.executionErrors, + float64(snapshot.inputBlocks)/elapsed, + float64(snapshot.finishedBlocks)/elapsed, + float64(snapshot.finishedTxs)/elapsed, + float64(snapshot.gasConsumed)/elapsed, + ) +} + +type metricsServer struct { + server *http.Server + done chan error +} + +func startMetricsServer(addr string, registry *prometheus.Registry) (*metricsServer, error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen for metrics on %s: %w", addr, err) + } + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok\n")) + }) + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 3 * time.Second, + } + ms := &metricsServer{server: server, done: make(chan error, 1)} + go func() { + err := server.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + ms.done <- err + }() + return ms, nil +} + +func (s *metricsServer) stop(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := s.server.Shutdown(ctx); err != nil { + return err + } + return <-s.done +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go new file mode 100644 index 0000000000..43813e30c6 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "testing" + + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--txs-per-block=4", + }) + require.NoError(t, err) + + state := newGeneratedState() + workload := newTransferWorkload(cfg, state) + request, err := workload.buildBlock(context.Background(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(context.Background(), request) + require.NoError(t, err) + + require.Len(t, result.Txs, cfg.txsPerBlock) + require.Len(t, result.Receipts, cfg.txsPerBlock) + require.Equal(t, uint64(cfg.txsPerBlock)*cfg.txGasLimit, result.GasUsed) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + } + + writer := &discardStateWriter{} + writer.ApplyChangeSet(result.ChangeSet) + discardReceiptSink{}.StoreReceipts(request.Context.Number, result.Receipts) +} + +func BenchmarkExecuteTransferBlock(b *testing.B) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--txs-per-block=1000", + }) + require.NoError(b, err) + + state := newGeneratedState() + workload := newTransferWorkload(cfg, state) + request, err := workload.buildBlock(context.Background(), 1) + require.NoError(b, err) + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + }, evmonly.WithState(state)) + + b.ReportAllocs() + b.SetBytes(int64(cfg.txsPerBlock)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, err := executor.ExecuteBlock(context.Background(), request) + if err != nil { + b.Fatal(err) + } + if len(result.Txs) != cfg.txsPerBlock { + b.Fatalf("expected %d txs, got %d", cfg.txsPerBlock, len(result.Txs)) + } + } +} From 15ce1e55dbb226eaeb599652f0cfc919d3269539 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 30 Jun 2026 11:23:54 +0800 Subject: [PATCH 02/34] add prebuilt block loadtest mode --- giga/evmonly/cmd/evmonly-loadtest/README.md | 22 +++ giga/evmonly/cmd/evmonly-loadtest/main.go | 126 +++++++++++++++++- .../evmonly/cmd/evmonly-loadtest/main_test.go | 19 +++ 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index fc3c4a0186..13490d01a4 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -38,6 +38,26 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ --queue-size=512 ``` +To isolate executor throughput from block generation, prebuild a bounded run +before starting executor workers: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --prebuild-blocks \ + --blocks=400 \ + --txs-per-block=5000 \ + --builders=48 \ + --workers=1 \ + --executor-workers=24 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 \ + --queue-size=512 +``` + +Prebuilding requires `--blocks > 0` and stores every raw block in memory. + The zero gas price/min-gas settings keep the transfer workload focused on the optimistic no-overlap case. Non-zero fees make every transaction update the same coinbase balance, which is a real intra-block conflict. @@ -52,6 +72,8 @@ Useful knobs: - `--queue-size`: buffered blocks ready for workers. The default is `64`. - `--target-blocks-per-sec`: cap block input rate. The default `0` feeds as fast as block generation and the queue allow. +- `--prebuild-blocks`: generate all bounded blocks before starting executor + workers. This separates build throughput from executor throughput. - `--metrics-addr`: Prometheus endpoint. The default is `127.0.0.1:9698`; set it to empty to disable HTTP metrics. - `--report-interval`: stdout rate reporting interval. The default is `5s`. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 235ea79cab..949a50fa08 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -67,6 +67,7 @@ type config struct { coinbase common.Address fixedRecipient *common.Address disableGasPriceRule bool + prebuildBlocks bool } type blockEnvelope struct { @@ -112,6 +113,7 @@ func parseConfig(args []string) (config, error) { fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") + fs.BoolVar(&cfg.prebuildBlocks, "prebuild-blocks", false, "generate all bounded blocks before starting executor workers") if err := fs.Parse(args); err != nil { return config{}, err @@ -171,6 +173,9 @@ func parseConfig(args []string) (config, error) { if cfg.txGasLimit == 0 { return config{}, fmt.Errorf("tx-gas-limit must be positive") } + if cfg.prebuildBlocks && cfg.blocks == 0 { + return config{}, fmt.Errorf("prebuild-blocks requires --blocks > 0") + } if !cfg.disableGasPriceRule && cfg.gasPrice.Cmp(cfg.minGasPrice) < 0 { return config{}, fmt.Errorf("gas-price-wei must be greater than or equal to min-gas-price-wei unless disable-gas-price-rule is set") } @@ -228,7 +233,6 @@ func run(cfg config) error { workload := newTransferWorkload(cfg, state) registry := prometheus.NewRegistry() metrics := newLoadMetrics(registry) - blocks := make(chan blockEnvelope, cfg.queueSize) var server *metricsServer if cfg.metricsAddr != "" { @@ -245,6 +249,14 @@ func run(cfg config) error { fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) } + if cfg.prebuildBlocks { + return runPrebuilt(ctx, cfg, state, workload, metrics) + } + return runStreaming(ctx, cfg, state, workload, metrics) +} + +func runStreaming(ctx context.Context, cfg config, state *generatedState, workload *transferWorkload, metrics *loadMetrics) error { + blocks := make(chan blockEnvelope, cfg.queueSize) reportCtx, stopReporter := context.WithCancel(ctx) reportDone := make(chan struct{}) go func() { @@ -281,6 +293,52 @@ func run(cfg config) error { return err } +func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload *transferWorkload, metrics *loadMetrics) error { + prebuildStartedAt := time.Now() + prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) + if err != nil { + return err + } + prebuildElapsed := time.Since(prebuildStartedAt) + printPrebuildReport(prebuildElapsed, prebuilt, cfg.txsPerBlock) + + blocks := make(chan blockEnvelope, cfg.queueSize) + reportCtx, stopReporter := context.WithCancel(ctx) + reportDone := make(chan struct{}) + go func() { + defer close(reportDone) + reportLoop(reportCtx, cfg.reportInterval, metrics, blocks) + }() + + startedAt := time.Now() + group, groupCtx := errgroup.WithContext(ctx) + group.Go(func() error { + defer close(blocks) + return feedPrebuiltBlocks(groupCtx, prebuilt, blocks, metrics) + }) + for workerID := 0; workerID < cfg.workers; workerID++ { + workerID := workerID + group.Go(func() error { + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: new(big.Int).Set(cfg.minGasPrice), + DisableGasPriceCheck: cfg.disableGasPriceRule, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state)) + return executeBlocks(groupCtx, workerID, executor, blocks, &discardStateWriter{}, discardReceiptSink{}, metrics) + }) + } + + err = group.Wait() + stopReporter() + <-reportDone + + if errors.Is(err, context.Canceled) { + err = nil + } + printFinalReport(startedAt, metrics.snapshot()) + return err +} + func produceBlocks(ctx context.Context, cfg config, workload *transferWorkload, out chan<- blockEnvelope, metrics *loadMetrics) error { var limiter *rate.Limiter if cfg.targetBlocksPerSec > 0 { @@ -325,6 +383,56 @@ func produceBlocks(ctx context.Context, cfg config, workload *transferWorkload, return group.Wait() } +func prebuildBlockRequests(ctx context.Context, cfg config, workload *transferWorkload) ([]blockEnvelope, error) { + if cfg.blocks > uint64(maxInt()) { + return nil, fmt.Errorf("prebuild-blocks cannot allocate %d blocks on this platform", cfg.blocks) + } + prebuilt := make([]blockEnvelope, int(cfg.blocks)) + var nextBlock atomic.Uint64 + group, groupCtx := errgroup.WithContext(ctx) + for builderID := 0; builderID < cfg.builders; builderID++ { + group.Go(func() error { + for { + number := nextBlock.Add(1) + if number > cfg.blocks { + return nil + } + request, err := workload.buildBlock(groupCtx, number) + if err != nil { + if groupCtx.Err() != nil { + return nil + } + return err + } + prebuilt[number-1] = blockEnvelope{ + number: number, + request: request, + } + } + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + return prebuilt, nil +} + +func feedPrebuiltBlocks(ctx context.Context, prebuilt []blockEnvelope, out chan<- blockEnvelope, metrics *loadMetrics) error { + for _, block := range prebuilt { + select { + case out <- block: + metrics.recordInput() + case <-ctx.Done(): + return nil + } + } + return nil +} + +func maxInt() int { + return int(^uint(0) >> 1) +} + func executeBlocks( ctx context.Context, workerID int, @@ -767,6 +875,22 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { ) } +func printPrebuildReport(elapsed time.Duration, blocks []blockEnvelope, txsPerBlock int) { + seconds := elapsed.Seconds() + if seconds <= 0 { + seconds = 1 + } + txCount := len(blocks) * txsPerBlock + fmt.Printf( + "prebuild complete elapsed=%s blocks=%d txs=%d build_blocks/s=%.2f build_tx/s=%.2f\n", + elapsed.Round(time.Millisecond), + len(blocks), + txCount, + float64(len(blocks))/seconds, + float64(txCount)/seconds, + ) +} + type metricsServer struct { server *http.Server done chan error diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index 43813e30c6..880bf0c307 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -41,6 +41,25 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { discardReceiptSink{}.StoreReceipts(request.Context.Number, result.Receipts) } +func TestPrebuildBlocksRequiresBoundedRun(t *testing.T) { + _, err := parseConfig([]string{ + "--prebuild-blocks", + }) + require.ErrorContains(t, err, "prebuild-blocks requires --blocks > 0") +} + +func TestRunPrebuiltBlocks(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--prebuild-blocks", + "--blocks=2", + "--txs-per-block=2", + }) + require.NoError(t, err) + require.NoError(t, run(cfg)) +} + func BenchmarkExecuteTransferBlock(b *testing.B) { cfg, err := parseConfig([]string{ "--metrics-addr=", From af98434f213064f0669aea987e65b409031a377d Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 30 Jun 2026 14:11:40 +0800 Subject: [PATCH 03/34] freeze generated loadtest state after prebuild --- giga/evmonly/cmd/evmonly-loadtest/main.go | 38 ++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 949a50fa08..9c79842dc4 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -299,6 +299,7 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa if err != nil { return err } + state.Freeze() prebuildElapsed := time.Since(prebuildStartedAt) printPrebuildReport(prebuildElapsed, prebuilt, cfg.txsPerBlock) @@ -482,7 +483,7 @@ func newTransferWorkload(cfg config, state *generatedState) *transferWorkload { func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { txs := make([][]byte, w.cfg.txsPerBlock) - for i := range txs { + for i := 0; i < w.cfg.txsPerBlock; i++ { select { case <-ctx.Done(): return evmonly.BlockRequest{}, ctx.Err() @@ -577,6 +578,7 @@ func hashFromSeed(prefix string, index uint64) common.Hash { type generatedState struct { mu sync.RWMutex + frozen atomic.Bool balances map[common.Address]*big.Int nonces map[common.Address]uint64 code map[common.Address][]byte @@ -585,6 +587,8 @@ type generatedState struct { var _ evmonly.StateReader = (*generatedState)(nil) +var frozenZeroBalance = new(big.Int) + func newGeneratedState() *generatedState { return &generatedState{ balances: map[common.Address]*big.Int{}, @@ -594,7 +598,17 @@ func newGeneratedState() *generatedState { } } +func (s *generatedState) Freeze() { + s.frozen.Store(true) +} + func (s *generatedState) GetBalance(addr common.Address) *big.Int { + if s.frozen.Load() { + if balance, ok := s.balances[addr]; ok && balance != nil { + return balance + } + return frozenZeroBalance + } s.mu.RLock() defer s.mu.RUnlock() if balance, ok := s.balances[addr]; ok && balance != nil { @@ -604,6 +618,7 @@ func (s *generatedState) GetBalance(addr common.Address) *big.Int { } func (s *generatedState) SetBalance(addr common.Address, balance *big.Int) { + s.requireMutable() s.mu.Lock() defer s.mu.Unlock() if balance == nil { @@ -614,30 +629,44 @@ func (s *generatedState) SetBalance(addr common.Address, balance *big.Int) { } func (s *generatedState) GetNonce(addr common.Address) uint64 { + if s.frozen.Load() { + return s.nonces[addr] + } s.mu.RLock() defer s.mu.RUnlock() return s.nonces[addr] } func (s *generatedState) SetNonce(addr common.Address, nonce uint64) { + s.requireMutable() s.mu.Lock() defer s.mu.Unlock() s.nonces[addr] = nonce } func (s *generatedState) GetCode(addr common.Address) []byte { + if s.frozen.Load() { + return cloneBytes(s.code[addr]) + } s.mu.RLock() defer s.mu.RUnlock() return cloneBytes(s.code[addr]) } func (s *generatedState) SetCode(addr common.Address, code []byte) { + s.requireMutable() s.mu.Lock() defer s.mu.Unlock() s.code[addr] = cloneBytes(code) } func (s *generatedState) GetState(addr common.Address, key common.Hash) common.Hash { + if s.frozen.Load() { + if accountStorage, ok := s.storage[addr]; ok { + return accountStorage[key] + } + return common.Hash{} + } s.mu.RLock() defer s.mu.RUnlock() if accountStorage, ok := s.storage[addr]; ok { @@ -647,6 +676,7 @@ func (s *generatedState) GetState(addr common.Address, key common.Hash) common.H } func (s *generatedState) SetState(addr common.Address, key common.Hash, value common.Hash) { + s.requireMutable() s.mu.Lock() defer s.mu.Unlock() accountStorage, ok := s.storage[addr] @@ -661,6 +691,12 @@ func (s *generatedState) SetState(addr common.Address, key common.Hash, value co accountStorage[key] = value } +func (s *generatedState) requireMutable() { + if s.frozen.Load() { + panic("generated state is frozen") + } +} + func cloneBytes(v []byte) []byte { if len(v) == 0 { return nil From 7c27a0a33461af0f7328fc0b1183718d04b4e89a Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 30 Jun 2026 16:12:34 +0800 Subject: [PATCH 04/34] add evmonly OCC fallback metrics --- giga/evmonly/cmd/evmonly-loadtest/main.go | 92 ++++++++++++++++++++++- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 9c79842dc4..ae6783e494 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -461,7 +461,7 @@ func executeBlocks( } stateWriter.ApplyChangeSet(result.ChangeSet) receiptSink.StoreReceipts(block.number, result.Receipts) - metrics.recordFinished(len(result.Txs), result.GasUsed) + metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) } } } @@ -724,12 +724,21 @@ type loadMetrics struct { finishedTxs atomic.Uint64 gasConsumed atomic.Uint64 executionErrors atomic.Uint64 + occAttempts atomic.Uint64 + occFallbacks atomic.Uint64 + occConflicts atomic.Uint64 inputBlocksTotal prometheus.Counter finishedBlocksTotal prometheus.Counter finishedTxsTotal prometheus.Counter gasConsumedTotal prometheus.Counter executionErrorsTotal prometheus.Counter + occAttemptsTotal prometheus.Counter + occFallbacksTotal prometheus.Counter + occConflictsTotal prometheus.Counter + + occFallbackReasonTotal *prometheus.CounterVec + occConflictTotal *prometheus.CounterVec inputBlockRate prometheus.Gauge finishedBlockRate prometheus.Gauge @@ -745,6 +754,9 @@ type metricsSnapshot struct { finishedTxs uint64 gasConsumed uint64 executionErrors uint64 + occAttempts uint64 + occFallbacks uint64 + occConflicts uint64 } func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { @@ -769,6 +781,26 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_execution_errors_total", Help: "Total block execution errors returned by the EVM-only executor.", }), + occAttemptsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_attempts_total", + Help: "Total blocks executed with optimistic concurrency control.", + }), + occFallbacksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_fallbacks_total", + Help: "Total OCC blocks that fell back to sequential execution.", + }), + occConflictsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_conflicts_total", + Help: "Total OCC conflict accesses observed before sequential fallback.", + }), + occFallbackReasonTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_fallback_reasons_total", + Help: "OCC fallback count by reason.", + }, []string{"reason"}), + occConflictTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_conflict_keys_total", + Help: "OCC conflict accesses by access type, state kind, address, and slot.", + }, []string{"access", "kind", "address", "slot"}), inputBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "evmonly_loadtest_block_input_throughput", Help: "Most recent measured block input throughput in blocks per second.", @@ -796,6 +828,11 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.finishedTxsTotal, m.gasConsumedTotal, m.executionErrorsTotal, + m.occAttemptsTotal, + m.occFallbacksTotal, + m.occConflictsTotal, + m.occFallbackReasonTotal, + m.occConflictTotal, m.inputBlockRate, m.finishedBlockRate, m.txRate, @@ -810,13 +847,51 @@ func (m *loadMetrics) recordInput() { m.inputBlocksTotal.Inc() } -func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64) { +func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64, occStats evmonly.OCCStats) { m.finishedBlocks.Add(1) m.finishedTxs.Add(uint64(txCount)) m.gasConsumed.Add(gasUsed) m.finishedBlocksTotal.Inc() m.finishedTxsTotal.Add(float64(txCount)) m.gasConsumedTotal.Add(float64(gasUsed)) + m.recordOCC(occStats) +} + +func (m *loadMetrics) recordOCC(stats evmonly.OCCStats) { + if !stats.Attempted { + return + } + m.occAttempts.Add(1) + m.occAttemptsTotal.Inc() + if stats.Fallback { + reason := stats.FallbackReason + if reason == "" { + reason = "unknown" + } + m.occFallbacks.Add(1) + m.occFallbacksTotal.Inc() + m.occFallbackReasonTotal.WithLabelValues(reason).Inc() + } + if stats.ConflictCount == 0 { + return + } + m.occConflicts.Add(stats.ConflictCount) + m.occConflictsTotal.Add(float64(stats.ConflictCount)) + for _, conflict := range stats.ConflictSamples { + m.occConflictTotal.WithLabelValues( + conflict.Access, + conflict.Kind, + conflict.Address.Hex(), + conflictSlotLabel(conflict), + ).Add(float64(conflict.Count)) + } +} + +func conflictSlotLabel(conflict evmonly.OCCConflictCount) string { + if conflict.Kind != "storage" { + return "" + } + return conflict.Slot.Hex() } func (m *loadMetrics) recordExecutionError() { @@ -832,6 +907,9 @@ func (m *loadMetrics) snapshot() metricsSnapshot { finishedTxs: m.finishedTxs.Load(), gasConsumed: m.gasConsumed.Load(), executionErrors: m.executionErrors.Load(), + occAttempts: m.occAttempts.Load(), + occFallbacks: m.occFallbacks.Load(), + occConflicts: m.occConflicts.Load(), } } @@ -874,7 +952,7 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric continue } fmt.Printf( - "input_blocks/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d totals(input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d)\n", + "input_blocks/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d totals(input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d)\n", float64(curr.inputBlocks-prev.inputBlocks)/elapsed, float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, float64(curr.finishedTxs-prev.finishedTxs)/elapsed, @@ -885,6 +963,9 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric curr.finishedTxs, curr.gasConsumed, curr.executionErrors, + curr.occAttempts, + curr.occFallbacks, + curr.occConflicts, ) prev = curr } @@ -897,13 +978,16 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { elapsed = 1 } fmt.Printf( - "complete elapsed=%s input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d avg_input_blocks/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + "complete elapsed=%s input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d avg_input_blocks/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", snapshot.at.Sub(startedAt).Round(time.Millisecond), snapshot.inputBlocks, snapshot.finishedBlocks, snapshot.finishedTxs, snapshot.gasConsumed, snapshot.executionErrors, + snapshot.occAttempts, + snapshot.occFallbacks, + snapshot.occConflicts, float64(snapshot.inputBlocks)/elapsed, float64(snapshot.finishedBlocks)/elapsed, float64(snapshot.finishedTxs)/elapsed, From 4c643d088b24e9b0d58e6a3cc16095832c758762 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 1 Jul 2026 11:33:33 +0800 Subject: [PATCH 05/34] add ERC20 transfer loadtest workload --- giga/evmonly/cmd/evmonly-loadtest/main.go | 167 ++++++++++++++++-- .../evmonly/cmd/evmonly-loadtest/main_test.go | 40 +++++ 2 files changed, 192 insertions(+), 15 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index ae6783e494..b74f1456e1 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -37,13 +37,17 @@ const ( defaultMinGasPriceWei = "1000000000" defaultSenderBalance = "1000000000000000000" defaultTransferValue = "1" + defaultERC20Contract = "0x000000000000000000000000000000000000e20c" defaultMetricsAddr = "127.0.0.1:9698" defaultReportInterval = 5 * time.Second defaultQueueSize = 64 defaultTxGasLimit = 21_000 + defaultERC20TxGasLimit = 100_000 defaultTxsPerBlock = 1_000 defaultWorkerCount = 1 defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" + workloadTransfer = "transfer" + workloadERC20Transfer = "erc20-transfer" ) type config struct { @@ -65,6 +69,7 @@ type config struct { txGasLimit uint64 blockGasLimit uint64 coinbase common.Address + erc20Contract common.Address fixedRecipient *common.Address disableGasPriceRule bool prebuildBlocks bool @@ -96,8 +101,9 @@ func parseConfig(args []string) (config, error) { gasPrice := fs.String("gas-price-wei", defaultGasPriceWei, "legacy transaction gas price in wei") minGasPrice := fs.String("min-gas-price-wei", defaultMinGasPriceWei, "executor minimum gas price in wei") senderBalance := fs.String("sender-balance-wei", defaultSenderBalance, "generated sender genesis balance in wei") - transferValue := fs.String("transfer-value-wei", defaultTransferValue, "wei transferred by each generated transaction") + transferValue := fs.String("transfer-value-wei", defaultTransferValue, "wei or token units transferred by each generated transaction") coinbase := fs.String("coinbase", defaultCoinbaseAddress, "block coinbase address") + erc20Contract := fs.String("erc20-contract", defaultERC20Contract, "EVM address for the generated ERC20 transfer contract") recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to feed; 0 runs until interrupted") @@ -109,7 +115,7 @@ func parseConfig(args []string) (config, error) { fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") - fs.StringVar(&cfg.workload, "workload", "transfer", "workload type; currently transfer") + fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer or erc20-transfer") fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") @@ -145,10 +151,23 @@ func parseConfig(args []string) (config, error) { addr := common.HexToAddress(*recipient) cfg.fixedRecipient = &addr } + if !common.IsHexAddress(*erc20Contract) { + return config{}, fmt.Errorf("erc20-contract must be a hex EVM address") + } + cfg.erc20Contract = common.HexToAddress(*erc20Contract) cfg.workload = strings.ToLower(strings.TrimSpace(cfg.workload)) - if cfg.workload != "transfer" { + if cfg.workload != workloadTransfer && cfg.workload != workloadERC20Transfer { return config{}, fmt.Errorf("unsupported workload %q", cfg.workload) } + txGasLimitSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "tx-gas-limit" { + txGasLimitSet = true + } + }) + if cfg.workload == workloadERC20Transfer && !txGasLimitSet { + cfg.txGasLimit = defaultERC20TxGasLimit + } if cfg.txsPerBlock <= 0 { return config{}, fmt.Errorf("txs-per-block must be positive") } @@ -173,6 +192,9 @@ func parseConfig(args []string) (config, error) { if cfg.txGasLimit == 0 { return config{}, fmt.Errorf("tx-gas-limit must be positive") } + if cfg.transferValue.BitLen() > 256 { + return config{}, fmt.Errorf("transfer-value-wei must fit in uint256") + } if cfg.prebuildBlocks && cfg.blocks == 0 { return config{}, fmt.Errorf("prebuild-blocks requires --blocks > 0") } @@ -180,9 +202,13 @@ func parseConfig(args []string) (config, error) { return config{}, fmt.Errorf("gas-price-wei must be greater than or equal to min-gas-price-wei unless disable-gas-price-rule is set") } requiredBalance := new(big.Int).Mul(new(big.Int).SetUint64(cfg.txGasLimit), cfg.gasPrice) - requiredBalance.Add(requiredBalance, cfg.transferValue) + requiredBalanceReason := "max gas cost" + if cfg.workload == workloadTransfer { + requiredBalance.Add(requiredBalance, cfg.transferValue) + requiredBalanceReason = "transfer value plus max gas cost" + } if cfg.senderBalance.Cmp(requiredBalance) < 0 { - return config{}, fmt.Errorf("sender-balance-wei must cover transfer value plus max gas cost: need at least %s", requiredBalance.String()) + return config{}, fmt.Errorf("sender-balance-wei must cover %s: need at least %s", requiredBalanceReason, requiredBalance.String()) } return cfg, nil } @@ -230,7 +256,10 @@ func run(cfg config) error { defer stop() state := newGeneratedState() - workload := newTransferWorkload(cfg, state) + workload, err := newWorkload(cfg, state) + if err != nil { + return err + } registry := prometheus.NewRegistry() metrics := newLoadMetrics(registry) @@ -255,7 +284,22 @@ func run(cfg config) error { return runStreaming(ctx, cfg, state, workload, metrics) } -func runStreaming(ctx context.Context, cfg config, state *generatedState, workload *transferWorkload, metrics *loadMetrics) error { +type blockWorkload interface { + buildBlock(context.Context, uint64) (evmonly.BlockRequest, error) +} + +func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { + switch cfg.workload { + case workloadTransfer: + return newTransferWorkload(cfg, state), nil + case workloadERC20Transfer: + return newERC20TransferWorkload(cfg, state), nil + default: + return nil, fmt.Errorf("unsupported workload %q", cfg.workload) + } +} + +func runStreaming(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, metrics *loadMetrics) error { blocks := make(chan blockEnvelope, cfg.queueSize) reportCtx, stopReporter := context.WithCancel(ctx) reportDone := make(chan struct{}) @@ -293,7 +337,7 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo return err } -func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload *transferWorkload, metrics *loadMetrics) error { +func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, metrics *loadMetrics) error { prebuildStartedAt := time.Now() prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) if err != nil { @@ -340,7 +384,7 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa return err } -func produceBlocks(ctx context.Context, cfg config, workload *transferWorkload, out chan<- blockEnvelope, metrics *loadMetrics) error { +func produceBlocks(ctx context.Context, cfg config, workload blockWorkload, out chan<- blockEnvelope, metrics *loadMetrics) error { var limiter *rate.Limiter if cfg.targetBlocksPerSec > 0 { burst := int(math.Ceil(cfg.targetBlocksPerSec)) @@ -384,7 +428,7 @@ func produceBlocks(ctx context.Context, cfg config, workload *transferWorkload, return group.Wait() } -func prebuildBlockRequests(ctx context.Context, cfg config, workload *transferWorkload) ([]blockEnvelope, error) { +func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorkload) ([]blockEnvelope, error) { if cfg.blocks > uint64(maxInt()) { return nil, fmt.Errorf("prebuild-blocks cannot allocate %d blocks on this platform", cfg.blocks) } @@ -498,7 +542,7 @@ func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmon txs[i] = raw } return evmonly.BlockRequest{ - Context: w.blockContext(number), + Context: blockContext(w.cfg, number), Txs: txs, }, nil } @@ -535,14 +579,107 @@ func (w *transferWorkload) recipient(accountIndex uint64) common.Address { return addressFromSeed("sei-evmonly-loadtest-recipient", accountIndex) } -func (w *transferWorkload) blockContext(number uint64) evmonly.BlockContext { +type erc20TransferWorkload struct { + cfg config + state *generatedState + signer ethtypes.Signer + accountCursor atomic.Uint64 +} + +var ( + erc20TransferSelector = [4]byte{0xa9, 0x05, 0x9c, 0xbb} + // Minimal ERC20-like runtime for transfer(address,uint256), with balances at + // storage slot 0 and a standard Transfer(address,address,uint256) log. + erc20TransferRuntimeCode = common.FromHex("0x60003560e01c63a9059cbb1460145760006000fd5b60243560043533600052600060205260406000208054831060805780548303905580600052600060205260406000208054830190558160005280337fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60206000a3600160005260206000f35b60006000fd") +) + +func newERC20TransferWorkload(cfg config, state *generatedState) *erc20TransferWorkload { + state.SetCode(cfg.erc20Contract, erc20TransferRuntimeCode) + return &erc20TransferWorkload{ + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.chainID), + } +} + +func (w *erc20TransferWorkload) buildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.txsPerBlock) + for i := 0; i < w.cfg.txsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + raw, sender, err := w.buildTransferTx(accountIndex) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.state.SetBalance(sender, w.cfg.senderBalance) + w.state.SetState(w.cfg.erc20Contract, erc20BalanceSlot(sender), common.BigToHash(w.cfg.transferValue)) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: blockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *erc20TransferWorkload) buildTransferTx(accountIndex uint64) ([]byte, common.Address, error) { + key, err := deterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + recipient := w.recipient(accountIndex) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: new(big.Int).Set(w.cfg.gasPrice), + Gas: w.cfg.txGasLimit, + To: &w.cfg.erc20Contract, + Value: new(big.Int), + Data: erc20TransferCalldata(recipient, w.cfg.transferValue), + }) + signed, err := ethtypes.SignTx(tx, w.signer, key) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} + +func (w *erc20TransferWorkload) recipient(accountIndex uint64) common.Address { + if w.cfg.fixedRecipient != nil { + return *w.cfg.fixedRecipient + } + return addressFromSeed("sei-evmonly-loadtest-erc20-recipient", accountIndex) +} + +func erc20TransferCalldata(recipient common.Address, amount *big.Int) []byte { + data := make([]byte, 4+32+32) + copy(data[:4], erc20TransferSelector[:]) + copy(data[4+12:36], recipient.Bytes()) + amount.FillBytes(data[36:68]) + return data +} + +func erc20BalanceSlot(owner common.Address) common.Hash { + var encoded [64]byte + copy(encoded[12:32], owner.Bytes()) + return crypto.Keccak256Hash(encoded[:]) +} + +func blockContext(cfg config, number uint64) evmonly.BlockContext { return evmonly.BlockContext{ Number: number, Time: uint64(time.Now().Unix()), - GasLimit: w.cfg.blockGasLimit, - ChainID: new(big.Int).Set(w.cfg.chainID), + GasLimit: cfg.blockGasLimit, + ChainID: new(big.Int).Set(cfg.chainID), BaseFee: big.NewInt(0), - Coinbase: w.cfg.coinbase, + Coinbase: cfg.coinbase, ParentHash: hashFromSeed("sei-evmonly-loadtest-parent", number-1), BlockHash: hashFromSeed("sei-evmonly-loadtest-block", number), PrevRandao: hashFromSeed("sei-evmonly-loadtest-randao", number), diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index 880bf0c307..dae649b3bc 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -41,6 +41,46 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { discardReceiptSink{}.StoreReceipts(request.Context.Number, result.Receipts) } +func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--workload=erc20-transfer", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + require.Equal(t, uint64(defaultERC20TxGasLimit), cfg.txGasLimit) + + state := newGeneratedState() + workload, err := newWorkload(cfg, state) + require.NoError(t, err) + request, err := workload.buildBlock(context.Background(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(context.Background(), request) + require.NoError(t, err) + + require.Len(t, result.Txs, cfg.txsPerBlock) + require.Len(t, result.Receipts, cfg.txsPerBlock) + require.NotEmpty(t, result.ChangeSet.Storage) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + require.Greater(t, tx.GasUsed, uint64(21_000)) + require.Len(t, tx.Logs, 1) + } + for _, receipt := range result.Receipts { + require.Len(t, receipt.Logs, 1) + } +} + func TestPrebuildBlocksRequiresBoundedRun(t *testing.T) { _, err := parseConfig([]string{ "--prebuild-blocks", From fd0b6153bdc567c613a81f13dab2a39a4d45329d Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 1 Jul 2026 11:57:08 +0800 Subject: [PATCH 06/34] verify ERC20 loadtest token balances --- .../evmonly/cmd/evmonly-loadtest/main_test.go | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index dae649b3bc..defdc3bd44 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -4,7 +4,9 @@ import ( "context" "testing" + "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/giga/evmonly" @@ -79,6 +81,40 @@ func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { for _, receipt := range result.Receipts { require.Len(t, receipt.Logs, 1) } + + applyGeneratedStateChangeSet(state, result.ChangeSet) + transferWorkload := workload.(*erc20TransferWorkload) + for i := uint64(1); i <= uint64(cfg.txsPerBlock); i++ { + key, err := deterministicPrivateKey(i) + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + recipient := transferWorkload.recipient(i) + require.Equal(t, common.Hash{}, state.GetState(cfg.erc20Contract, erc20BalanceSlot(sender))) + require.Equal(t, common.BigToHash(cfg.transferValue), state.GetState(cfg.erc20Contract, erc20BalanceSlot(recipient))) + } +} + +func applyGeneratedStateChangeSet(state *generatedState, changeSet evmonly.StateChangeSet) { + for _, change := range changeSet.Balances { + state.SetBalance(change.Address, change.Balance) + } + for _, change := range changeSet.Nonces { + state.SetNonce(change.Address, change.Nonce) + } + for _, change := range changeSet.Code { + if change.Delete { + state.SetCode(change.Address, nil) + } else { + state.SetCode(change.Address, change.Code) + } + } + for _, change := range changeSet.Storage { + value := change.Value + if change.Delete { + value = common.Hash{} + } + state.SetState(change.Address, change.Key, value) + } } func TestPrebuildBlocksRequiresBoundedRun(t *testing.T) { From 3fe649f6a651b71cd6ee9a166fc434711c759091 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 1 Jul 2026 12:07:59 +0800 Subject: [PATCH 07/34] add persistent loadtest result sink --- giga/evmonly/cmd/evmonly-loadtest/main.go | 235 +++++++++++++++++- .../evmonly/cmd/evmonly-loadtest/main_test.go | 45 ++++ 2 files changed, 268 insertions(+), 12 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index b74f1456e1..36b3a08c7e 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "crypto/ecdsa" "encoding/binary" @@ -13,6 +14,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "runtime" "strings" "sync" @@ -23,6 +25,7 @@ import ( "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" @@ -44,10 +47,13 @@ const ( defaultTxGasLimit = 21_000 defaultERC20TxGasLimit = 100_000 defaultTxsPerBlock = 1_000 + defaultPersistBuffer = 4 << 20 defaultWorkerCount = 1 defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" workloadTransfer = "transfer" workloadERC20Transfer = "erc20-transfer" + resultSinkDiscard = "discard" + resultSinkFile = "file" ) type config struct { @@ -60,6 +66,10 @@ type config struct { targetBlocksPerSec float64 reportInterval time.Duration metricsAddr string + resultSink string + persistDir string + persistSync bool + persistBufferSize int workload string chainID *big.Int gasPrice *big.Int @@ -115,6 +125,10 @@ func parseConfig(args []string) (config, error) { fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") + fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") + fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files") + fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files after every block; file sink always flushes each block") + fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer or erc20-transfer") fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") @@ -189,6 +203,16 @@ func parseConfig(args []string) (config, error) { if cfg.reportInterval < 0 { return config{}, fmt.Errorf("report-interval must be non-negative") } + cfg.resultSink = strings.ToLower(strings.TrimSpace(cfg.resultSink)) + if cfg.resultSink != resultSinkDiscard && cfg.resultSink != resultSinkFile { + return config{}, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) + } + if cfg.persistBufferSize <= 0 { + return config{}, fmt.Errorf("persist-buffer-size must be positive") + } + if cfg.resultSink == resultSinkFile && strings.TrimSpace(cfg.persistDir) == "" { + return config{}, fmt.Errorf("persist-dir is required when result-sink=file") + } if cfg.txGasLimit == 0 { return config{}, fmt.Errorf("tx-gas-limit must be positive") } @@ -262,6 +286,10 @@ func run(cfg config) error { } registry := prometheus.NewRegistry() metrics := newLoadMetrics(registry) + sinks, err := newResultSinks(cfg) + if err != nil { + return err + } var server *metricsServer if cfg.metricsAddr != "" { @@ -278,10 +306,20 @@ func run(cfg config) error { fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) } + var runErr error if cfg.prebuildBlocks { - return runPrebuilt(ctx, cfg, state, workload, metrics) + runErr = runPrebuilt(ctx, cfg, state, workload, sinks, metrics) + } else { + runErr = runStreaming(ctx, cfg, state, workload, sinks, metrics) + } + if closeErr := sinks.Close(); closeErr != nil { + if runErr != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink close: %v\n", closeErr) + return runErr + } + return closeErr } - return runStreaming(ctx, cfg, state, workload, metrics) + return runErr } type blockWorkload interface { @@ -299,7 +337,7 @@ func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { } } -func runStreaming(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, metrics *loadMetrics) error { +func runStreaming(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) error { blocks := make(chan blockEnvelope, cfg.queueSize) reportCtx, stopReporter := context.WithCancel(ctx) reportDone := make(chan struct{}) @@ -322,7 +360,7 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, }, evmonly.WithState(state)) - return executeBlocks(groupCtx, workerID, executor, blocks, &discardStateWriter{}, discardReceiptSink{}, metrics) + return executeBlocks(groupCtx, workerID, executor, blocks, sinks, metrics) }) } @@ -337,7 +375,7 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo return err } -func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, metrics *loadMetrics) error { +func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) error { prebuildStartedAt := time.Now() prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) if err != nil { @@ -369,7 +407,7 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, }, evmonly.WithState(state)) - return executeBlocks(groupCtx, workerID, executor, blocks, &discardStateWriter{}, discardReceiptSink{}, metrics) + return executeBlocks(groupCtx, workerID, executor, blocks, sinks, metrics) }) } @@ -483,8 +521,7 @@ func executeBlocks( workerID int, executor evmonly.BlockExecutor, blocks <-chan blockEnvelope, - stateWriter evmonly.StateWriter, - receiptSink receiptSink, + sinks *resultSinks, metrics *loadMetrics, ) error { for { @@ -503,8 +540,14 @@ func executeBlocks( } return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) } - stateWriter.ApplyChangeSet(result.ChangeSet) - receiptSink.StoreReceipts(block.number, result.Receipts) + if err := sinks.StoreChangeSet(block.number, result.ChangeSet); err != nil { + metrics.recordExecutionError() + return fmt.Errorf("worker %d store changeset for block %d: %w", workerID, block.number, err) + } + if err := sinks.StoreReceipts(block.number, result.Receipts); err != nil { + metrics.recordExecutionError() + return fmt.Errorf("worker %d store receipts for block %d: %w", workerID, block.number, err) + } metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) } } @@ -847,13 +890,181 @@ var _ evmonly.StateWriter = (*discardStateWriter)(nil) func (*discardStateWriter) ApplyChangeSet(evmonly.StateChangeSet) {} +type changeSetSink interface { + StoreChangeSet(height uint64, changeSet evmonly.StateChangeSet) error +} + type receiptSink interface { - StoreReceipts(height uint64, receipts ethtypes.Receipts) + StoreReceipts(height uint64, receipts ethtypes.Receipts) error +} + +type resultSinks struct { + changeSets changeSetSink + receipts receiptSink + close func() error +} + +func newResultSinks(cfg config) (*resultSinks, error) { + switch cfg.resultSink { + case resultSinkDiscard: + return &resultSinks{ + changeSets: discardChangeSetSink{writer: &discardStateWriter{}}, + receipts: discardReceiptSink{}, + }, nil + case resultSinkFile: + return newFileResultSinks(cfg) + default: + return nil, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) + } +} + +func (s *resultSinks) StoreChangeSet(height uint64, changeSet evmonly.StateChangeSet) error { + return s.changeSets.StoreChangeSet(height, changeSet) +} + +func (s *resultSinks) StoreReceipts(height uint64, receipts ethtypes.Receipts) error { + return s.receipts.StoreReceipts(height, receipts) +} + +func (s *resultSinks) Close() error { + if s.close == nil { + return nil + } + return s.close() +} + +type discardChangeSetSink struct { + writer evmonly.StateWriter +} + +func (s discardChangeSetSink) StoreChangeSet(_ uint64, changeSet evmonly.StateChangeSet) error { + s.writer.ApplyChangeSet(changeSet) + return nil } type discardReceiptSink struct{} -func (discardReceiptSink) StoreReceipts(uint64, ethtypes.Receipts) {} +func (discardReceiptSink) StoreReceipts(uint64, ethtypes.Receipts) error { + return nil +} + +type fileResultSinks struct { + changeSetFile *appendRLPFile + receiptFile *appendRLPFile +} + +func newFileResultSinks(cfg config) (*resultSinks, error) { + if err := os.MkdirAll(cfg.persistDir, 0o755); err != nil { + return nil, fmt.Errorf("create persist dir %s: %w", cfg.persistDir, err) + } + files := &fileResultSinks{} + var err error + files.changeSetFile, err = newAppendRLPFile(filepath.Join(cfg.persistDir, "changesets.rlp"), cfg.persistBufferSize, cfg.persistSync) + if err != nil { + return nil, err + } + files.receiptFile, err = newAppendRLPFile(filepath.Join(cfg.persistDir, "receipts.rlp"), cfg.persistBufferSize, cfg.persistSync) + if err != nil { + closeErr := files.changeSetFile.Close() + return nil, errors.Join(err, closeErr) + } + return &resultSinks{ + changeSets: fileChangeSetSink{file: files.changeSetFile}, + receipts: fileReceiptSink{file: files.receiptFile}, + close: files.Close, + }, nil +} + +func (s *fileResultSinks) Close() error { + return errors.Join(s.changeSetFile.Close(), s.receiptFile.Close()) +} + +type fileChangeSetSink struct { + file *appendRLPFile +} + +func (s fileChangeSetSink) StoreChangeSet(height uint64, changeSet evmonly.StateChangeSet) error { + return s.file.WriteRecord(height, changeSet) +} + +type fileReceiptSink struct { + file *appendRLPFile +} + +func (s fileReceiptSink) StoreReceipts(height uint64, receipts ethtypes.Receipts) error { + return s.file.WriteRecord(height, receipts) +} + +type appendRLPFile struct { + mu sync.Mutex + file *os.File + writer *bufio.Writer + syncOnWrite bool + closed bool +} + +func newAppendRLPFile(path string, bufferSize int, syncOnWrite bool) (*appendRLPFile, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("open persist file %s: %w", path, err) + } + return &appendRLPFile{ + file: file, + writer: bufio.NewWriterSize(file, bufferSize), + syncOnWrite: syncOnWrite, + }, nil +} + +func (f *appendRLPFile) WriteRecord(height uint64, value any) error { + payload, err := rlp.EncodeToBytes(value) + if err != nil { + return fmt.Errorf("encode rlp record for height %d: %w", height, err) + } + var header [16]byte + binary.BigEndian.PutUint64(header[:8], height) + binary.BigEndian.PutUint64(header[8:], uint64(len(payload))) + + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return fmt.Errorf("write record for height %d: persist file is closed", height) + } + if _, err := f.writer.Write(header[:]); err != nil { + return fmt.Errorf("write record header for height %d: %w", height, err) + } + if _, err := f.writer.Write(payload); err != nil { + return fmt.Errorf("write record payload for height %d: %w", height, err) + } + if err := f.writer.Flush(); err != nil { + return fmt.Errorf("flush record for height %d: %w", height, err) + } + if f.syncOnWrite { + if err := f.file.Sync(); err != nil { + return fmt.Errorf("sync record for height %d: %w", height, err) + } + } + return nil +} + +func (f *appendRLPFile) sync() error { + if err := f.writer.Flush(); err != nil { + return err + } + if err := f.file.Sync(); err != nil { + return fmt.Errorf("sync persist file: %w", err) + } + return nil +} + +func (f *appendRLPFile) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return nil + } + f.closed = true + return errors.Join(f.sync(), f.file.Close()) +} type loadMetrics struct { inputBlocks atomic.Uint64 diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index defdc3bd44..4e71cedea8 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -2,11 +2,16 @@ package main import ( "context" + "encoding/binary" + "fmt" + "os" + "path/filepath" "testing" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/giga/evmonly" @@ -136,6 +141,46 @@ func TestRunPrebuiltBlocks(t *testing.T) { require.NoError(t, run(cfg)) } +func TestRunPrebuiltBlocksWithFileResultSink(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--prebuild-blocks", + "--blocks=2", + "--txs-per-block=2", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + "--result-sink=file", + "--persist-dir=" + dir, + }) + require.NoError(t, err) + require.NoError(t, run(cfg)) + + var changeSet evmonly.StateChangeSet + height := readPersistedRLPRecord(t, filepath.Join(dir, "changesets.rlp"), &changeSet) + require.Equal(t, uint64(1), height) + require.NotEmpty(t, changeSet.Balances) + + var receipts ethtypes.Receipts + height = readPersistedRLPRecord(t, filepath.Join(dir, "receipts.rlp"), &receipts) + require.Equal(t, uint64(1), height) + require.Len(t, receipts, cfg.txsPerBlock) +} + +func readPersistedRLPRecord(t *testing.T, path string, out any) uint64 { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + require.GreaterOrEqual(t, len(data), 16) + height := binary.BigEndian.Uint64(data[:8]) + payloadLen := binary.BigEndian.Uint64(data[8:16]) + require.LessOrEqual(t, payloadLen, uint64(len(data)-16)) + payload := data[16 : 16+payloadLen] + require.NoError(t, rlp.DecodeBytes(payload, out), fmt.Sprintf("decode %s", path)) + return height +} + func BenchmarkExecuteTransferBlock(b *testing.B) { cfg, err := parseConfig([]string{ "--metrics-addr=", From 69f2b5478cbd76ad03b4a2618aeb322cb618910e Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 1 Jul 2026 12:16:08 +0800 Subject: [PATCH 08/34] clean up persistent loadtest files --- giga/evmonly/cmd/evmonly-loadtest/main.go | 105 +++++++++++++++--- .../evmonly/cmd/evmonly-loadtest/main_test.go | 78 +++++++++++-- 2 files changed, 154 insertions(+), 29 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 36b3a08c7e..ce506fdd6f 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -275,7 +275,7 @@ func parseBig(name, raw string) (*big.Int, error) { return v, nil } -func run(cfg config) error { +func run(cfg config) (err error) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -290,6 +290,17 @@ func run(cfg config) error { if err != nil { return err } + defer func() { + if closeErr := sinks.Close(); closeErr != nil { + if err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink close: %v\n", closeErr) + return + } + err = closeErr + } + }() + stopSinkSignalCleanup := cleanupSinksOnContextCancel(ctx, sinks) + defer stopSinkSignalCleanup() var server *metricsServer if cfg.metricsAddr != "" { @@ -306,20 +317,33 @@ func run(cfg config) error { fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) } - var runErr error if cfg.prebuildBlocks { - runErr = runPrebuilt(ctx, cfg, state, workload, sinks, metrics) - } else { - runErr = runStreaming(ctx, cfg, state, workload, sinks, metrics) + return runPrebuilt(ctx, cfg, state, workload, sinks, metrics) } - if closeErr := sinks.Close(); closeErr != nil { - if runErr != nil { - fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink close: %v\n", closeErr) - return runErr + return runStreaming(ctx, cfg, state, workload, sinks, metrics) +} + +func cleanupSinksOnContextCancel(ctx context.Context, sinks *resultSinks) func() { + cleanupCtx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + select { + case <-ctx.Done(): + if err := sinks.Cleanup(); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink cleanup: %v\n", err) + } + case <-cleanupCtx.Done(): } - return closeErr + }() + return func() { + select { + case <-ctx.Done(): + default: + cancel() + } + <-done } - return runErr } type blockWorkload interface { @@ -902,6 +926,7 @@ type resultSinks struct { changeSets changeSetSink receipts receiptSink close func() error + cleanup func() error } func newResultSinks(cfg config) (*resultSinks, error) { @@ -927,10 +952,20 @@ func (s *resultSinks) StoreReceipts(height uint64, receipts ethtypes.Receipts) e } func (s *resultSinks) Close() error { + var closeErr error if s.close == nil { + closeErr = nil + } else { + closeErr = s.close() + } + return errors.Join(closeErr, s.Cleanup()) +} + +func (s *resultSinks) Cleanup() error { + if s.cleanup == nil { return nil } - return s.close() + return s.cleanup() } type discardChangeSetSink struct { @@ -951,32 +986,66 @@ func (discardReceiptSink) StoreReceipts(uint64, ethtypes.Receipts) error { type fileResultSinks struct { changeSetFile *appendRLPFile receiptFile *appendRLPFile + cleanupMu sync.Mutex + paths []string + cleaned map[string]struct{} } func newFileResultSinks(cfg config) (*resultSinks, error) { if err := os.MkdirAll(cfg.persistDir, 0o755); err != nil { return nil, fmt.Errorf("create persist dir %s: %w", cfg.persistDir, err) } - files := &fileResultSinks{} + changeSetPath := filepath.Join(cfg.persistDir, "changesets.rlp") + receiptPath := filepath.Join(cfg.persistDir, "receipts.rlp") + files := &fileResultSinks{ + paths: []string{changeSetPath, receiptPath}, + cleaned: map[string]struct{}{}, + } var err error - files.changeSetFile, err = newAppendRLPFile(filepath.Join(cfg.persistDir, "changesets.rlp"), cfg.persistBufferSize, cfg.persistSync) + files.changeSetFile, err = newAppendRLPFile(changeSetPath, cfg.persistBufferSize, cfg.persistSync) if err != nil { return nil, err } - files.receiptFile, err = newAppendRLPFile(filepath.Join(cfg.persistDir, "receipts.rlp"), cfg.persistBufferSize, cfg.persistSync) + files.receiptFile, err = newAppendRLPFile(receiptPath, cfg.persistBufferSize, cfg.persistSync) if err != nil { - closeErr := files.changeSetFile.Close() - return nil, errors.Join(err, closeErr) + return nil, errors.Join(err, files.Close()) } return &resultSinks{ changeSets: fileChangeSetSink{file: files.changeSetFile}, receipts: fileReceiptSink{file: files.receiptFile}, close: files.Close, + cleanup: files.Cleanup, }, nil } func (s *fileResultSinks) Close() error { - return errors.Join(s.changeSetFile.Close(), s.receiptFile.Close()) + var errs []error + if s.changeSetFile != nil { + errs = append(errs, s.changeSetFile.Close()) + } + if s.receiptFile != nil { + errs = append(errs, s.receiptFile.Close()) + } + errs = append(errs, s.Cleanup()) + return errors.Join(errs...) +} + +func (s *fileResultSinks) Cleanup() error { + s.cleanupMu.Lock() + defer s.cleanupMu.Unlock() + + var errs []error + for _, path := range s.paths { + if _, ok := s.cleaned[path]; ok { + continue + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("remove persist file %s: %w", path, err)) + continue + } + s.cleaned[path] = struct{}{} + } + return errors.Join(errs...) } type fileChangeSetSink struct { diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index 4e71cedea8..4b20749bdc 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -3,7 +3,9 @@ package main import ( "context" "encoding/binary" + "errors" "fmt" + "math/big" "os" "path/filepath" "testing" @@ -141,31 +143,73 @@ func TestRunPrebuiltBlocks(t *testing.T) { require.NoError(t, run(cfg)) } -func TestRunPrebuiltBlocksWithFileResultSink(t *testing.T) { +func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", - "--report-interval=0", - "--prebuild-blocks", - "--blocks=2", - "--txs-per-block=2", - "--gas-price-wei=0", - "--min-gas-price-wei=0", "--result-sink=file", "--persist-dir=" + dir, }) require.NoError(t, err) - require.NoError(t, run(cfg)) + sinks, err := newResultSinks(cfg) + require.NoError(t, err) + changePath := filepath.Join(dir, "changesets.rlp") + receiptPath := filepath.Join(dir, "receipts.rlp") + writtenChangeSet := evmonly.StateChangeSet{ + Balances: []evmonly.BalanceChange{{ + Address: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Balance: big.NewInt(7), + }}, + } + writtenReceipts := ethtypes.Receipts{{ + Type: ethtypes.LegacyTxType, + Status: ethtypes.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0x01"), + }} + require.NoError(t, sinks.StoreChangeSet(1, writtenChangeSet)) + require.NoError(t, sinks.StoreReceipts(1, writtenReceipts)) + + requireFileExists(t, changePath) + requireFileExists(t, receiptPath) var changeSet evmonly.StateChangeSet - height := readPersistedRLPRecord(t, filepath.Join(dir, "changesets.rlp"), &changeSet) + height := readPersistedRLPRecord(t, changePath, &changeSet) require.Equal(t, uint64(1), height) require.NotEmpty(t, changeSet.Balances) var receipts ethtypes.Receipts - height = readPersistedRLPRecord(t, filepath.Join(dir, "receipts.rlp"), &receipts) + height = readPersistedRLPRecord(t, receiptPath, &receipts) require.Equal(t, uint64(1), height) - require.Len(t, receipts, cfg.txsPerBlock) + require.Len(t, receipts, 1) + + ctx, cancel := context.WithCancel(context.Background()) + stopCleanup := cleanupSinksOnContextCancel(ctx, sinks) + cancel() + stopCleanup() + requireNoFileExists(t, changePath) + requireNoFileExists(t, receiptPath) + require.NoError(t, sinks.Close()) + requireNoFileExists(t, changePath) + requireNoFileExists(t, receiptPath) +} + +func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--prebuild-blocks", + "--blocks=2", + "--txs-per-block=2", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + "--result-sink=file", + "--persist-dir=" + dir, + }) + require.NoError(t, err) + require.NoError(t, run(cfg)) + requireNoFileExists(t, filepath.Join(dir, "changesets.rlp")) + requireNoFileExists(t, filepath.Join(dir, "receipts.rlp")) } func readPersistedRLPRecord(t *testing.T, path string, out any) uint64 { @@ -181,6 +225,18 @@ func readPersistedRLPRecord(t *testing.T, path string, out any) uint64 { return height } +func requireFileExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + require.NoError(t, err) +} + +func requireNoFileExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + require.Truef(t, errors.Is(err, os.ErrNotExist), "expected %s to be removed, got %v", path, err) +} + func BenchmarkExecuteTransferBlock(b *testing.B) { cfg, err := parseConfig([]string{ "--metrics-addr=", From 7c73081557e71d23513c6a75051d7b3c85c2bc08 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 13:33:27 +0800 Subject: [PATCH 09/34] add async persistence backpressure metrics --- giga/evmonly/cmd/evmonly-loadtest/main.go | 377 ++++++++++++++++-- .../evmonly/cmd/evmonly-loadtest/main_test.go | 37 +- 2 files changed, 378 insertions(+), 36 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index ce506fdd6f..d09f9831e3 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -54,6 +54,8 @@ const ( workloadERC20Transfer = "erc20-transfer" resultSinkDiscard = "discard" resultSinkFile = "file" + resultSinkChangeSet = "changeset" + resultSinkReceipts = "receipts" ) type config struct { @@ -69,7 +71,9 @@ type config struct { resultSink string persistDir string persistSync bool + persistAsync bool persistBufferSize int + persistQueueSize int workload string chainID *big.Int gasPrice *big.Int @@ -128,7 +132,9 @@ func parseConfig(args []string) (config, error) { fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files") fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files after every block; file sink always flushes each block") + fs.BoolVar(&cfg.persistAsync, "persist-async", true, "write persistent result files from a bounded async queue") fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") + fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for --persist-async; 0 defaults to 2*queue-size") fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer or erc20-transfer") fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") @@ -210,6 +216,12 @@ func parseConfig(args []string) (config, error) { if cfg.persistBufferSize <= 0 { return config{}, fmt.Errorf("persist-buffer-size must be positive") } + if cfg.persistQueueSize < 0 { + return config{}, fmt.Errorf("persist-queue-size must be non-negative") + } + if cfg.persistQueueSize == 0 { + cfg.persistQueueSize = 2 * cfg.queueSize + } if cfg.resultSink == resultSinkFile && strings.TrimSpace(cfg.persistDir) == "" { return config{}, fmt.Errorf("persist-dir is required when result-sink=file") } @@ -286,11 +298,12 @@ func run(cfg config) (err error) { } registry := prometheus.NewRegistry() metrics := newLoadMetrics(registry) - sinks, err := newResultSinks(cfg) + sinks, err := newResultSinks(cfg, metrics) if err != nil { return err } defer func() { + closeStartedAt := time.Now() if closeErr := sinks.Close(); closeErr != nil { if err != nil { fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink close: %v\n", closeErr) @@ -298,6 +311,9 @@ func run(cfg config) (err error) { } err = closeErr } + if cfg.resultSink == resultSinkFile { + printResultSinkReport(time.Since(closeStartedAt), metrics.snapshot()) + } }() stopSinkSignalCleanup := cleanupSinksOnContextCancel(ctx, sinks) defer stopSinkSignalCleanup() @@ -564,11 +580,11 @@ func executeBlocks( } return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) } - if err := sinks.StoreChangeSet(block.number, result.ChangeSet); err != nil { + if err := sinks.StoreChangeSet(ctx, block.number, result.ChangeSet); err != nil { metrics.recordExecutionError() return fmt.Errorf("worker %d store changeset for block %d: %w", workerID, block.number, err) } - if err := sinks.StoreReceipts(block.number, result.Receipts); err != nil { + if err := sinks.StoreReceipts(ctx, block.number, result.Receipts); err != nil { metrics.recordExecutionError() return fmt.Errorf("worker %d store receipts for block %d: %w", workerID, block.number, err) } @@ -915,11 +931,11 @@ var _ evmonly.StateWriter = (*discardStateWriter)(nil) func (*discardStateWriter) ApplyChangeSet(evmonly.StateChangeSet) {} type changeSetSink interface { - StoreChangeSet(height uint64, changeSet evmonly.StateChangeSet) error + StoreChangeSet(ctx context.Context, height uint64, changeSet evmonly.StateChangeSet) error } type receiptSink interface { - StoreReceipts(height uint64, receipts ethtypes.Receipts) error + StoreReceipts(ctx context.Context, height uint64, receipts ethtypes.Receipts) error } type resultSinks struct { @@ -929,7 +945,7 @@ type resultSinks struct { cleanup func() error } -func newResultSinks(cfg config) (*resultSinks, error) { +func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { switch cfg.resultSink { case resultSinkDiscard: return &resultSinks{ @@ -937,18 +953,18 @@ func newResultSinks(cfg config) (*resultSinks, error) { receipts: discardReceiptSink{}, }, nil case resultSinkFile: - return newFileResultSinks(cfg) + return newFileResultSinks(cfg, metrics) default: return nil, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) } } -func (s *resultSinks) StoreChangeSet(height uint64, changeSet evmonly.StateChangeSet) error { - return s.changeSets.StoreChangeSet(height, changeSet) +func (s *resultSinks) StoreChangeSet(ctx context.Context, height uint64, changeSet evmonly.StateChangeSet) error { + return s.changeSets.StoreChangeSet(ctx, height, changeSet) } -func (s *resultSinks) StoreReceipts(height uint64, receipts ethtypes.Receipts) error { - return s.receipts.StoreReceipts(height, receipts) +func (s *resultSinks) StoreReceipts(ctx context.Context, height uint64, receipts ethtypes.Receipts) error { + return s.receipts.StoreReceipts(ctx, height, receipts) } func (s *resultSinks) Close() error { @@ -972,32 +988,34 @@ type discardChangeSetSink struct { writer evmonly.StateWriter } -func (s discardChangeSetSink) StoreChangeSet(_ uint64, changeSet evmonly.StateChangeSet) error { +func (s discardChangeSetSink) StoreChangeSet(_ context.Context, _ uint64, changeSet evmonly.StateChangeSet) error { s.writer.ApplyChangeSet(changeSet) return nil } type discardReceiptSink struct{} -func (discardReceiptSink) StoreReceipts(uint64, ethtypes.Receipts) error { +func (discardReceiptSink) StoreReceipts(context.Context, uint64, ethtypes.Receipts) error { return nil } type fileResultSinks struct { changeSetFile *appendRLPFile receiptFile *appendRLPFile + metrics *loadMetrics cleanupMu sync.Mutex paths []string cleaned map[string]struct{} } -func newFileResultSinks(cfg config) (*resultSinks, error) { +func newFileResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { if err := os.MkdirAll(cfg.persistDir, 0o755); err != nil { return nil, fmt.Errorf("create persist dir %s: %w", cfg.persistDir, err) } changeSetPath := filepath.Join(cfg.persistDir, "changesets.rlp") receiptPath := filepath.Join(cfg.persistDir, "receipts.rlp") files := &fileResultSinks{ + metrics: metrics, paths: []string{changeSetPath, receiptPath}, cleaned: map[string]struct{}{}, } @@ -1010,9 +1028,18 @@ func newFileResultSinks(cfg config) (*resultSinks, error) { if err != nil { return nil, errors.Join(err, files.Close()) } + if cfg.persistAsync { + async := newAsyncFileResultSinks(files, cfg.persistQueueSize, metrics) + return &resultSinks{ + changeSets: async, + receipts: async, + close: async.Close, + cleanup: files.Cleanup, + }, nil + } return &resultSinks{ - changeSets: fileChangeSetSink{file: files.changeSetFile}, - receipts: fileReceiptSink{file: files.receiptFile}, + changeSets: fileChangeSetSink{files: files}, + receipts: fileReceiptSink{files: files}, close: files.Close, cleanup: files.Cleanup, }, nil @@ -1048,20 +1075,171 @@ func (s *fileResultSinks) Cleanup() error { return errors.Join(errs...) } +func (s *fileResultSinks) WriteRecord(kind string, height uint64, value any) error { + var file *appendRLPFile + switch kind { + case resultSinkChangeSet: + file = s.changeSetFile + case resultSinkReceipts: + file = s.receiptFile + default: + return fmt.Errorf("unsupported result sink record kind %q", kind) + } + startedAt := time.Now() + bytes, err := file.WriteRecord(height, value) + elapsed := time.Since(startedAt) + if s.metrics != nil { + s.metrics.recordSinkWrite(kind, bytes, elapsed, err == nil) + } + return err +} + type fileChangeSetSink struct { - file *appendRLPFile + files *fileResultSinks } -func (s fileChangeSetSink) StoreChangeSet(height uint64, changeSet evmonly.StateChangeSet) error { - return s.file.WriteRecord(height, changeSet) +func (s fileChangeSetSink) StoreChangeSet(_ context.Context, height uint64, changeSet evmonly.StateChangeSet) error { + return s.files.WriteRecord(resultSinkChangeSet, height, changeSet) } type fileReceiptSink struct { - file *appendRLPFile + files *fileResultSinks +} + +func (s fileReceiptSink) StoreReceipts(_ context.Context, height uint64, receipts ethtypes.Receipts) error { + return s.files.WriteRecord(resultSinkReceipts, height, receipts) +} + +type resultSinkRecord struct { + kind string + height uint64 + value any +} + +type asyncFileResultSinks struct { + files *fileResultSinks + metrics *loadMetrics + records chan resultSinkRecord + done chan struct{} + closeErr error + close sync.Once + errMu sync.Mutex + err error +} + +func newAsyncFileResultSinks(files *fileResultSinks, queueSize int, metrics *loadMetrics) *asyncFileResultSinks { + s := &asyncFileResultSinks{ + files: files, + metrics: metrics, + records: make(chan resultSinkRecord, queueSize), + done: make(chan struct{}), + } + if metrics != nil { + metrics.setSinkQueueCapacity(queueSize) + } + go s.run() + return s +} + +func (s *asyncFileResultSinks) StoreChangeSet(ctx context.Context, height uint64, changeSet evmonly.StateChangeSet) error { + return s.enqueue(ctx, resultSinkRecord{ + kind: resultSinkChangeSet, + height: height, + value: changeSet, + }) +} + +func (s *asyncFileResultSinks) StoreReceipts(ctx context.Context, height uint64, receipts ethtypes.Receipts) error { + return s.enqueue(ctx, resultSinkRecord{ + kind: resultSinkReceipts, + height: height, + value: receipts, + }) +} + +func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRecord) error { + if err := s.getErr(); err != nil { + return err + } + select { + case s.records <- record: + s.recordEnqueued(record.kind) + return nil + default: + } + + startedAt := time.Now() + select { + case s.records <- record: + if s.metrics != nil { + s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) + } + s.recordEnqueued(record.kind) + return nil + case <-s.done: + if err := s.getErr(); err != nil { + return err + } + return fmt.Errorf("result sink is closed") + case <-ctx.Done(): + if s.metrics != nil { + s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) + } + return ctx.Err() + } } -func (s fileReceiptSink) StoreReceipts(height uint64, receipts ethtypes.Receipts) error { - return s.file.WriteRecord(height, receipts) +func (s *asyncFileResultSinks) recordEnqueued(kind string) { + if s.metrics == nil { + return + } + s.metrics.recordSinkEnqueued(kind) + s.metrics.setSinkQueued(len(s.records)) +} + +func (s *asyncFileResultSinks) run() { + defer close(s.done) + for record := range s.records { + if s.metrics != nil { + s.metrics.setSinkQueued(len(s.records)) + } + if err := s.files.WriteRecord(record.kind, record.height, record.value); err != nil { + s.setErr(err) + return + } + if s.metrics != nil { + s.metrics.setSinkQueued(len(s.records)) + } + } + if s.metrics != nil { + s.metrics.setSinkQueued(0) + } +} + +func (s *asyncFileResultSinks) Close() error { + s.close.Do(func() { + close(s.records) + <-s.done + if s.metrics != nil { + s.metrics.setSinkQueued(0) + } + s.closeErr = errors.Join(s.getErr(), s.files.Close()) + }) + return s.closeErr +} + +func (s *asyncFileResultSinks) setErr(err error) { + s.errMu.Lock() + defer s.errMu.Unlock() + if s.err == nil { + s.err = err + } +} + +func (s *asyncFileResultSinks) getErr() error { + s.errMu.Lock() + defer s.errMu.Unlock() + return s.err } type appendRLPFile struct { @@ -1084,10 +1262,10 @@ func newAppendRLPFile(path string, bufferSize int, syncOnWrite bool) (*appendRLP }, nil } -func (f *appendRLPFile) WriteRecord(height uint64, value any) error { +func (f *appendRLPFile) WriteRecord(height uint64, value any) (int, error) { payload, err := rlp.EncodeToBytes(value) if err != nil { - return fmt.Errorf("encode rlp record for height %d: %w", height, err) + return 0, fmt.Errorf("encode rlp record for height %d: %w", height, err) } var header [16]byte binary.BigEndian.PutUint64(header[:8], height) @@ -1096,23 +1274,23 @@ func (f *appendRLPFile) WriteRecord(height uint64, value any) error { f.mu.Lock() defer f.mu.Unlock() if f.closed { - return fmt.Errorf("write record for height %d: persist file is closed", height) + return 0, fmt.Errorf("write record for height %d: persist file is closed", height) } if _, err := f.writer.Write(header[:]); err != nil { - return fmt.Errorf("write record header for height %d: %w", height, err) + return 0, fmt.Errorf("write record header for height %d: %w", height, err) } if _, err := f.writer.Write(payload); err != nil { - return fmt.Errorf("write record payload for height %d: %w", height, err) + return 0, fmt.Errorf("write record payload for height %d: %w", height, err) } if err := f.writer.Flush(); err != nil { - return fmt.Errorf("flush record for height %d: %w", height, err) + return 0, fmt.Errorf("flush record for height %d: %w", height, err) } if f.syncOnWrite { if err := f.file.Sync(); err != nil { - return fmt.Errorf("sync record for height %d: %w", height, err) + return 0, fmt.Errorf("sync record for height %d: %w", height, err) } } - return nil + return len(header) + len(payload), nil } func (f *appendRLPFile) sync() error { @@ -1144,6 +1322,13 @@ type loadMetrics struct { occAttempts atomic.Uint64 occFallbacks atomic.Uint64 occConflicts atomic.Uint64 + sinkEnqueued atomic.Uint64 + sinkWritten atomic.Uint64 + sinkBytes atomic.Uint64 + sinkWaitNanos atomic.Uint64 + sinkWaitEvents atomic.Uint64 + sinkWriteNanos atomic.Uint64 + sinkQueued atomic.Int64 inputBlocksTotal prometheus.Counter finishedBlocksTotal prometheus.Counter @@ -1156,12 +1341,20 @@ type loadMetrics struct { occFallbackReasonTotal *prometheus.CounterVec occConflictTotal *prometheus.CounterVec + sinkEnqueuedTotal *prometheus.CounterVec + sinkWrittenTotal *prometheus.CounterVec + sinkBytesTotal *prometheus.CounterVec + sinkEnqueueWaitTotal prometheus.Counter + sinkEnqueueWaitEvents prometheus.Counter + sinkWriteSecondsTotal *prometheus.CounterVec inputBlockRate prometheus.Gauge finishedBlockRate prometheus.Gauge txRate prometheus.Gauge gasRate prometheus.Gauge queuedBlocks prometheus.Gauge + sinkQueuedRecords prometheus.Gauge + sinkQueueCapacity prometheus.Gauge } type metricsSnapshot struct { @@ -1174,6 +1367,13 @@ type metricsSnapshot struct { occAttempts uint64 occFallbacks uint64 occConflicts uint64 + sinkEnqueued uint64 + sinkWritten uint64 + sinkBytes uint64 + sinkWaitNanos uint64 + sinkWaitEvents uint64 + sinkWriteNanos uint64 + sinkQueued int64 } func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { @@ -1218,6 +1418,30 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_occ_conflict_keys_total", Help: "OCC conflict accesses by access type, state kind, address, and slot.", }, []string{"access", "kind", "address", "slot"}), + sinkEnqueuedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_records_enqueued_total", + Help: "Total records accepted by the result sink.", + }, []string{"kind"}), + sinkWrittenTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_records_written_total", + Help: "Total records written by the result sink.", + }, []string{"kind"}), + sinkBytesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_bytes_written_total", + Help: "Total bytes written by the result sink, including record framing.", + }, []string{"kind"}), + sinkEnqueueWaitTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_enqueue_wait_seconds_total", + Help: "Total time executor workers spent blocked enqueueing result sink records.", + }), + sinkEnqueueWaitEvents: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_enqueue_wait_events_total", + Help: "Total result sink enqueue attempts that had to wait for queue capacity or cancellation.", + }), + sinkWriteSecondsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_write_seconds_total", + Help: "Total time spent by result sink writers serializing, writing, flushing, and optionally syncing records.", + }, []string{"kind"}), inputBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "evmonly_loadtest_block_input_throughput", Help: "Most recent measured block input throughput in blocks per second.", @@ -1238,6 +1462,14 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_queued_blocks", Help: "Blocks currently waiting in the executor input queue.", }), + sinkQueuedRecords: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_sink_queued_records", + Help: "Persistent result sink records currently waiting for the async writer.", + }), + sinkQueueCapacity: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_sink_queue_capacity", + Help: "Capacity of the persistent result sink async record queue.", + }), } registry.MustRegister( m.inputBlocksTotal, @@ -1250,11 +1482,19 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.occConflictsTotal, m.occFallbackReasonTotal, m.occConflictTotal, + m.sinkEnqueuedTotal, + m.sinkWrittenTotal, + m.sinkBytesTotal, + m.sinkEnqueueWaitTotal, + m.sinkEnqueueWaitEvents, + m.sinkWriteSecondsTotal, m.inputBlockRate, m.finishedBlockRate, m.txRate, m.gasRate, m.queuedBlocks, + m.sinkQueuedRecords, + m.sinkQueueCapacity, ) return m } @@ -1316,6 +1556,44 @@ func (m *loadMetrics) recordExecutionError() { m.executionErrorsTotal.Inc() } +func (m *loadMetrics) recordSinkEnqueued(kind string) { + m.sinkEnqueued.Add(1) + m.sinkEnqueuedTotal.WithLabelValues(kind).Inc() +} + +func (m *loadMetrics) recordSinkEnqueueWait(elapsed time.Duration) { + if elapsed <= 0 { + return + } + m.sinkWaitNanos.Add(uint64(elapsed.Nanoseconds())) + m.sinkWaitEvents.Add(1) + m.sinkEnqueueWaitTotal.Add(elapsed.Seconds()) + m.sinkEnqueueWaitEvents.Inc() +} + +func (m *loadMetrics) recordSinkWrite(kind string, bytes int, elapsed time.Duration, completed bool) { + if elapsed > 0 { + m.sinkWriteNanos.Add(uint64(elapsed.Nanoseconds())) + m.sinkWriteSecondsTotal.WithLabelValues(kind).Add(elapsed.Seconds()) + } + if !completed { + return + } + m.sinkWritten.Add(1) + m.sinkBytes.Add(uint64(bytes)) + m.sinkWrittenTotal.WithLabelValues(kind).Inc() + m.sinkBytesTotal.WithLabelValues(kind).Add(float64(bytes)) +} + +func (m *loadMetrics) setSinkQueued(records int) { + m.sinkQueued.Store(int64(records)) + m.sinkQueuedRecords.Set(float64(records)) +} + +func (m *loadMetrics) setSinkQueueCapacity(records int) { + m.sinkQueueCapacity.Set(float64(records)) +} + func (m *loadMetrics) snapshot() metricsSnapshot { return metricsSnapshot{ at: time.Now(), @@ -1327,6 +1605,13 @@ func (m *loadMetrics) snapshot() metricsSnapshot { occAttempts: m.occAttempts.Load(), occFallbacks: m.occFallbacks.Load(), occConflicts: m.occConflicts.Load(), + sinkEnqueued: m.sinkEnqueued.Load(), + sinkWritten: m.sinkWritten.Load(), + sinkBytes: m.sinkBytes.Load(), + sinkWaitNanos: m.sinkWaitNanos.Load(), + sinkWaitEvents: m.sinkWaitEvents.Load(), + sinkWriteNanos: m.sinkWriteNanos.Load(), + sinkQueued: m.sinkQueued.Load(), } } @@ -1368,13 +1653,18 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric prev = curr continue } + sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) + sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) fmt.Printf( - "input_blocks/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d totals(input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d)\n", + "input_blocks/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_enqueued=%d sink_written=%d)\n", float64(curr.inputBlocks-prev.inputBlocks)/elapsed, float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, float64(curr.finishedTxs-prev.finishedTxs)/elapsed, float64(curr.gasConsumed-prev.gasConsumed)/elapsed, queued, + curr.sinkQueued, + sinkWaitSeconds/elapsed, + sinkWriteSeconds/elapsed, curr.inputBlocks, curr.finishedBlocks, curr.finishedTxs, @@ -1383,6 +1673,8 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric curr.occAttempts, curr.occFallbacks, curr.occConflicts, + curr.sinkEnqueued, + curr.sinkWritten, ) prev = curr } @@ -1395,7 +1687,7 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { elapsed = 1 } fmt.Printf( - "complete elapsed=%s input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d avg_input_blocks/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + "complete elapsed=%s input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s avg_input_blocks/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", snapshot.at.Sub(startedAt).Round(time.Millisecond), snapshot.inputBlocks, snapshot.finishedBlocks, @@ -1405,6 +1697,13 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { snapshot.occAttempts, snapshot.occFallbacks, snapshot.occConflicts, + snapshot.sinkQueued, + snapshot.sinkEnqueued, + snapshot.sinkWritten, + snapshot.sinkBytes, + time.Duration(snapshot.sinkWaitNanos).Round(time.Microsecond), + snapshot.sinkWaitEvents, + time.Duration(snapshot.sinkWriteNanos).Round(time.Microsecond), float64(snapshot.inputBlocks)/elapsed, float64(snapshot.finishedBlocks)/elapsed, float64(snapshot.finishedTxs)/elapsed, @@ -1412,6 +1711,20 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { ) } +func printResultSinkReport(closeElapsed time.Duration, snapshot metricsSnapshot) { + fmt.Printf( + "result sink close elapsed=%s sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s\n", + closeElapsed.Round(time.Millisecond), + snapshot.sinkQueued, + snapshot.sinkEnqueued, + snapshot.sinkWritten, + snapshot.sinkBytes, + time.Duration(snapshot.sinkWaitNanos).Round(time.Microsecond), + snapshot.sinkWaitEvents, + time.Duration(snapshot.sinkWriteNanos).Round(time.Microsecond), + ) +} + func printPrebuildReport(elapsed time.Duration, blocks []blockEnvelope, txsPerBlock int) { seconds := elapsed.Seconds() if seconds <= 0 { diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index 4b20749bdc..ac2c183771 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -14,6 +14,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rlp" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/giga/evmonly" @@ -47,7 +48,7 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { writer := &discardStateWriter{} writer.ApplyChangeSet(result.ChangeSet) - discardReceiptSink{}.StoreReceipts(request.Context.Number, result.Receipts) + require.NoError(t, discardReceiptSink{}.StoreReceipts(context.Background(), request.Context.Number, result.Receipts)) } func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { @@ -147,11 +148,13 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", + "--persist-async=false", "--result-sink=file", "--persist-dir=" + dir, }) require.NoError(t, err) - sinks, err := newResultSinks(cfg) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) require.NoError(t, err) changePath := filepath.Join(dir, "changesets.rlp") @@ -167,8 +170,8 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { Status: ethtypes.ReceiptStatusSuccessful, TxHash: common.HexToHash("0x01"), }} - require.NoError(t, sinks.StoreChangeSet(1, writtenChangeSet)) - require.NoError(t, sinks.StoreReceipts(1, writtenReceipts)) + require.NoError(t, sinks.StoreChangeSet(context.Background(), 1, writtenChangeSet)) + require.NoError(t, sinks.StoreReceipts(context.Background(), 1, writtenReceipts)) requireFileExists(t, changePath) requireFileExists(t, receiptPath) @@ -193,6 +196,32 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { requireNoFileExists(t, receiptPath) } +func TestAsyncFileResultSinkReportsMetrics(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--result-sink=file", + "--persist-dir=" + dir, + "--persist-queue-size=1", + }) + require.NoError(t, err) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + require.NoError(t, sinks.StoreChangeSet(ctx, 1, evmonly.StateChangeSet{})) + require.NoError(t, sinks.StoreReceipts(ctx, 1, ethtypes.Receipts{})) + require.NoError(t, sinks.Close()) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(2), snapshot.sinkEnqueued) + require.Equal(t, uint64(2), snapshot.sinkWritten) + require.Equal(t, int64(0), snapshot.sinkQueued) + require.Greater(t, snapshot.sinkBytes, uint64(0)) +} + func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ From 98bd3577f9ad7312193e73106d43a710c0447adf Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 13:55:03 +0800 Subject: [PATCH 10/34] wire loadtest sinks through evmonly executor --- giga/evmonly/cmd/evmonly-loadtest/main.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index d09f9831e3..2f92d16098 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -399,8 +399,8 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, - }, evmonly.WithState(state)) - return executeBlocks(groupCtx, workerID, executor, blocks, sinks, metrics) + }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) + return executeBlocks(groupCtx, workerID, executor, blocks, metrics) }) } @@ -446,8 +446,8 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, - }, evmonly.WithState(state)) - return executeBlocks(groupCtx, workerID, executor, blocks, sinks, metrics) + }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) + return executeBlocks(groupCtx, workerID, executor, blocks, metrics) }) } @@ -561,7 +561,6 @@ func executeBlocks( workerID int, executor evmonly.BlockExecutor, blocks <-chan blockEnvelope, - sinks *resultSinks, metrics *loadMetrics, ) error { for { @@ -580,14 +579,6 @@ func executeBlocks( } return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) } - if err := sinks.StoreChangeSet(ctx, block.number, result.ChangeSet); err != nil { - metrics.recordExecutionError() - return fmt.Errorf("worker %d store changeset for block %d: %w", workerID, block.number, err) - } - if err := sinks.StoreReceipts(ctx, block.number, result.Receipts); err != nil { - metrics.recordExecutionError() - return fmt.Errorf("worker %d store receipts for block %d: %w", workerID, block.number, err) - } metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) } } @@ -945,6 +936,8 @@ type resultSinks struct { cleanup func() error } +var _ evmonly.ResultSink = (*resultSinks)(nil) + func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { switch cfg.resultSink { case resultSinkDiscard: From 5d3ac208ea8959297a7adc4e7b9a5c5838cdda2b Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 14:20:11 +0800 Subject: [PATCH 11/34] remove synchronous loadtest file sink --- giga/evmonly/cmd/evmonly-loadtest/main.go | 38 +++---------------- .../evmonly/cmd/evmonly-loadtest/main_test.go | 5 ++- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 2f92d16098..97ee119c4f 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -71,7 +71,6 @@ type config struct { resultSink string persistDir string persistSync bool - persistAsync bool persistBufferSize int persistQueueSize int workload string @@ -131,10 +130,9 @@ func parseConfig(args []string) (config, error) { fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files") - fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files after every block; file sink always flushes each block") - fs.BoolVar(&cfg.persistAsync, "persist-async", true, "write persistent result files from a bounded async queue") + fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") - fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for --persist-async; 0 defaults to 2*queue-size") + fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for async file persistence; 0 defaults to 2*queue-size") fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer or erc20-transfer") fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") @@ -1021,19 +1019,11 @@ func newFileResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) if err != nil { return nil, errors.Join(err, files.Close()) } - if cfg.persistAsync { - async := newAsyncFileResultSinks(files, cfg.persistQueueSize, metrics) - return &resultSinks{ - changeSets: async, - receipts: async, - close: async.Close, - cleanup: files.Cleanup, - }, nil - } + async := newAsyncFileResultSinks(files, cfg.persistQueueSize, metrics) return &resultSinks{ - changeSets: fileChangeSetSink{files: files}, - receipts: fileReceiptSink{files: files}, - close: files.Close, + changeSets: async, + receipts: async, + close: async.Close, cleanup: files.Cleanup, }, nil } @@ -1087,22 +1077,6 @@ func (s *fileResultSinks) WriteRecord(kind string, height uint64, value any) err return err } -type fileChangeSetSink struct { - files *fileResultSinks -} - -func (s fileChangeSetSink) StoreChangeSet(_ context.Context, height uint64, changeSet evmonly.StateChangeSet) error { - return s.files.WriteRecord(resultSinkChangeSet, height, changeSet) -} - -type fileReceiptSink struct { - files *fileResultSinks -} - -func (s fileReceiptSink) StoreReceipts(_ context.Context, height uint64, receipts ethtypes.Receipts) error { - return s.files.WriteRecord(resultSinkReceipts, height, receipts) -} - type resultSinkRecord struct { kind string height uint64 diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index ac2c183771..d719738706 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -148,7 +149,6 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", - "--persist-async=false", "--result-sink=file", "--persist-dir=" + dir, }) @@ -172,6 +172,9 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { }} require.NoError(t, sinks.StoreChangeSet(context.Background(), 1, writtenChangeSet)) require.NoError(t, sinks.StoreReceipts(context.Background(), 1, writtenReceipts)) + require.Eventually(t, func() bool { + return metrics.snapshot().sinkWritten == 2 + }, time.Second, time.Millisecond) requireFileExists(t, changePath) requireFileExists(t, receiptPath) From 599e184674fe309d405c0b2a93ac491d2ae7f26d Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 15:25:40 +0800 Subject: [PATCH 12/34] add loadtest profiling flags --- giga/evmonly/cmd/evmonly-loadtest/main.go | 131 +++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 97ee119c4f..480958a421 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -16,6 +16,8 @@ import ( "os/signal" "path/filepath" "runtime" + "runtime/pprof" + "runtime/trace" "strings" "sync" "sync/atomic" @@ -73,6 +75,9 @@ type config struct { persistSync bool persistBufferSize int persistQueueSize int + cpuProfile string + heapProfile string + traceProfile string workload string chainID *big.Int gasPrice *big.Int @@ -133,6 +138,9 @@ func parseConfig(args []string) (config, error) { fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for async file persistence; 0 defaults to 2*queue-size") + fs.StringVar(&cfg.cpuProfile, "cpu-profile", "", "write Go CPU profile to this file; starts after prebuild when --prebuild-blocks is set") + fs.StringVar(&cfg.heapProfile, "heap-profile", "", "write Go heap profile to this file after execution") + fs.StringVar(&cfg.traceProfile, "trace-profile", "", "write Go runtime trace to this file; starts after prebuild when --prebuild-blocks is set") fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer or erc20-transfer") fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") @@ -337,6 +345,110 @@ func run(cfg config) (err error) { return runStreaming(ctx, cfg, state, workload, sinks, metrics) } +type profileSession struct { + cpuFile *os.File + traceFile *os.File + heapPath string + cpuActive bool + traceActive bool + heapComplete bool +} + +func startProfiles(cfg config) (*profileSession, error) { + if cfg.cpuProfile == "" && cfg.heapProfile == "" && cfg.traceProfile == "" { + return nil, nil + } + session := &profileSession{heapPath: cfg.heapProfile} + if cfg.cpuProfile != "" { + file, err := createProfileFile(cfg.cpuProfile) + if err != nil { + return nil, err + } + session.cpuFile = file + if err := pprof.StartCPUProfile(file); err != nil { + _ = session.Close() + return nil, fmt.Errorf("start CPU profile: %w", err) + } + session.cpuActive = true + } + if cfg.traceProfile != "" { + file, err := createProfileFile(cfg.traceProfile) + if err != nil { + _ = session.Close() + return nil, err + } + session.traceFile = file + if err := trace.Start(file); err != nil { + _ = session.Close() + return nil, fmt.Errorf("start runtime trace: %w", err) + } + session.traceActive = true + } + return session, nil +} + +func createProfileFile(path string) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create profile dir for %s: %w", path, err) + } + file, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("create profile file %s: %w", path, err) + } + return file, nil +} + +func (p *profileSession) Close() error { + if p == nil { + return nil + } + var errs []error + if p.traceActive { + trace.Stop() + p.traceActive = false + } + if p.cpuActive { + pprof.StopCPUProfile() + p.cpuActive = false + } + if p.cpuFile != nil { + errs = append(errs, p.cpuFile.Close()) + p.cpuFile = nil + } + if p.traceFile != nil { + errs = append(errs, p.traceFile.Close()) + p.traceFile = nil + } + if p.heapPath != "" && !p.heapComplete { + errs = append(errs, writeHeapProfile(p.heapPath)) + p.heapComplete = true + } + return errors.Join(errs...) +} + +func writeHeapProfile(path string) error { + file, err := createProfileFile(path) + if err != nil { + return err + } + defer file.Close() + runtime.GC() + if err := pprof.WriteHeapProfile(file); err != nil { + return fmt.Errorf("write heap profile %s: %w", path, err) + } + return nil +} + +func finishProfiles(session *profileSession, err *error) { + if closeErr := session.Close(); closeErr != nil { + if *err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: write profile: %v\n", closeErr) + return + } + *err = closeErr + } +} + func cleanupSinksOnContextCancel(ctx context.Context, sinks *resultSinks) func() { cleanupCtx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) @@ -375,7 +487,12 @@ func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { } } -func runStreaming(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) error { +func runStreaming(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) (err error) { + profiles, err := startProfiles(cfg) + if err != nil { + return err + } + blocks := make(chan blockEnvelope, cfg.queueSize) reportCtx, stopReporter := context.WithCancel(ctx) reportDone := make(chan struct{}) @@ -402,7 +519,7 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo }) } - err := group.Wait() + err = group.Wait() stopReporter() <-reportDone @@ -410,10 +527,11 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo err = nil } printFinalReport(startedAt, metrics.snapshot()) + finishProfiles(profiles, &err) return err } -func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) error { +func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) (err error) { prebuildStartedAt := time.Now() prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) if err != nil { @@ -423,6 +541,11 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa prebuildElapsed := time.Since(prebuildStartedAt) printPrebuildReport(prebuildElapsed, prebuilt, cfg.txsPerBlock) + profiles, err := startProfiles(cfg) + if err != nil { + return err + } + blocks := make(chan blockEnvelope, cfg.queueSize) reportCtx, stopReporter := context.WithCancel(ctx) reportDone := make(chan struct{}) @@ -457,6 +580,8 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa err = nil } printFinalReport(startedAt, metrics.snapshot()) + prebuilt = nil + finishProfiles(profiles, &err) return err } From 7fa0b6d8a045ee236c7301c0aeb085c2b5de175e Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 16:39:58 +0800 Subject: [PATCH 13/34] pipeline loadtest sender recovery --- giga/evmonly/cmd/evmonly-loadtest/README.md | 31 +-- giga/evmonly/cmd/evmonly-loadtest/main.go | 246 ++++++++++++++++++-- 2 files changed, 244 insertions(+), 33 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 13490d01a4..fb0185bcde 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -3,12 +3,11 @@ `evmonly-loadtest` is a standalone executable for feeding synthetic blocks to the EVM-only executor without Cosmos SDK state, mempool, RPC, or persistence. -It currently generates pure EVM legacy transfer transactions. Each generated -sender account has exactly one nonce-0 transaction and is funded in the -command's in-memory genesis state before its block is queued. Recipients are -unique by default so the transfer workload can be extended toward -less-conflicting account layouts; pass `--recipient=0x...` to force a single -recipient. +It currently generates pure EVM legacy transfer transactions and ERC20 transfer +transactions. Each generated sender account has exactly one nonce-0 transaction +and is funded in the command's in-memory genesis state before its block is +queued. Recipients are unique by default so the workloads exercise the optimistic +no-overlap case; pass `--recipient=0x...` to force a single recipient. Run a bounded test: @@ -39,7 +38,7 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ ``` To isolate executor throughput from block generation, prebuild a bounded run -before starting executor workers: +before starting the prepare/recover and executor workers: ```bash go run ./giga/evmonly/cmd/evmonly-loadtest \ @@ -57,6 +56,8 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ ``` Prebuilding requires `--blocks > 0` and stores every raw block in memory. +Sender recovery still runs in the measured phase, but it is pipelined ahead of +execution through `--prepare-workers`. The zero gas price/min-gas settings keep the transfer workload focused on the optimistic no-overlap case. Non-zero fees make every transaction update the @@ -67,9 +68,11 @@ Useful knobs: - `--workers`: parallel executor workers. The default is `1`. - `--executor-workers`: parallel OCC workers inside each executor. The default is `min(12, GOMAXPROCS)`, following the `sei-v3` OCC worker default. +- `--prepare-workers`: parallel stateless preparation workers used for + transaction RLP decode and sender recovery. The default is `GOMAXPROCS`. - `--builders`: parallel block builders used to keep the input queue full. The default is `GOMAXPROCS`. -- `--queue-size`: buffered blocks ready for workers. The default is `64`. +- `--queue-size`: buffered raw and prepared blocks. The default is `64`. - `--target-blocks-per-sec`: cap block input rate. The default `0` feeds as fast as block generation and the queue allow. - `--prebuild-blocks`: generate all bounded blocks before starting executor @@ -79,14 +82,17 @@ Useful knobs: - `--report-interval`: stdout rate reporting interval. The default is `5s`. - `--gas-price-wei`, `--min-gas-price-wei`, `--sender-balance-wei`, `--transfer-value-wei`: transaction economics for the generated accounts. +- `--workload`: workload type, either `transfer` or `erc20-transfer`. The command reports these saturation signals on stdout and at `/metrics`: - block input throughput +- block preparation throughput +- prepared transactions per second - block finishing throughput - finished transactions per second - total gas consumed per second -- queued blocks and cumulative totals +- prepared blocks queued for execution and cumulative totals The executor output is intentionally discarded through mocks: @@ -96,7 +102,6 @@ The executor output is intentionally discarded through mocks: executor `StateChangeSet`. - `discardReceiptSink` sinks Ethereum receipts. -Future workloads should add another workload builder beside `transferWorkload`. -ERC20 transfers can reuse the same harness by adding contract code/storage to -`generatedState`, generating calldata transactions, and keeping the same block -producer/executor/metrics pipeline. +Future workloads should add another workload builder beside `transferWorkload` +and `erc20TransferWorkload`, then reuse the same block +producer/prepare/executor/metrics pipeline. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 480958a421..4ea5b85383 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -65,6 +65,7 @@ type config struct { txsPerBlock int queueSize int builders int + prepareWorkers int workers int executorWorkers int targetBlocksPerSec float64 @@ -98,6 +99,11 @@ type blockEnvelope struct { request evmonly.BlockRequest } +type preparedBlockEnvelope struct { + number uint64 + block evmonly.PreparedBlock +} + func main() { cfg, err := parseConfig(os.Args[1:]) if err != nil { @@ -128,6 +134,7 @@ func parseConfig(args []string) (config, error) { fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") + fs.IntVar(&cfg.prepareWorkers, "prepare-workers", defaultPrepareWorkers(), "parallel block preparation workers for transaction decode and sender recovery") fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") @@ -203,6 +210,9 @@ func parseConfig(args []string) (config, error) { if cfg.builders <= 0 { return config{}, fmt.Errorf("builders must be positive") } + if cfg.prepareWorkers <= 0 { + return config{}, fmt.Errorf("prepare-workers must be positive") + } if cfg.workers <= 0 { return config{}, fmt.Errorf("workers must be positive") } @@ -255,6 +265,10 @@ func parseConfig(args []string) (config, error) { return cfg, nil } +func defaultPrepareWorkers() int { + return runtime.GOMAXPROCS(0) +} + func defaultExecutorWorkers() int { workers := runtime.GOMAXPROCS(0) if workers > 12 { @@ -494,28 +508,33 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo } blocks := make(chan blockEnvelope, cfg.queueSize) + preparedBlocks := make(chan preparedBlockEnvelope, cfg.queueSize) reportCtx, stopReporter := context.WithCancel(ctx) reportDone := make(chan struct{}) go func() { defer close(reportDone) - reportLoop(reportCtx, cfg.reportInterval, metrics, blocks) + reportLoop(reportCtx, cfg.reportInterval, metrics, preparedBlocks) }() startedAt := time.Now() group, groupCtx := errgroup.WithContext(ctx) + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: new(big.Int).Set(cfg.minGasPrice), + DisableGasPriceCheck: cfg.disableGasPriceRule, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) group.Go(func() error { defer close(blocks) return produceBlocks(groupCtx, cfg, workload, blocks, metrics) }) + group.Go(func() error { + defer close(preparedBlocks) + return prepareBlocks(groupCtx, cfg, executor, blocks, preparedBlocks, metrics) + }) for workerID := 0; workerID < cfg.workers; workerID++ { workerID := workerID group.Go(func() error { - executor := evmonly.NewExecutor(evmonly.Config{ - MinGasPrice: new(big.Int).Set(cfg.minGasPrice), - DisableGasPriceCheck: cfg.disableGasPriceRule, - OCCWorkers: cfg.executorWorkers, - }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) - return executeBlocks(groupCtx, workerID, executor, blocks, metrics) + return executeBlocks(groupCtx, workerID, executor, preparedBlocks, metrics) }) } @@ -547,28 +566,33 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa } blocks := make(chan blockEnvelope, cfg.queueSize) + preparedBlocks := make(chan preparedBlockEnvelope, cfg.queueSize) reportCtx, stopReporter := context.WithCancel(ctx) reportDone := make(chan struct{}) go func() { defer close(reportDone) - reportLoop(reportCtx, cfg.reportInterval, metrics, blocks) + reportLoop(reportCtx, cfg.reportInterval, metrics, preparedBlocks) }() startedAt := time.Now() group, groupCtx := errgroup.WithContext(ctx) + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: new(big.Int).Set(cfg.minGasPrice), + DisableGasPriceCheck: cfg.disableGasPriceRule, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) group.Go(func() error { defer close(blocks) return feedPrebuiltBlocks(groupCtx, prebuilt, blocks, metrics) }) + group.Go(func() error { + defer close(preparedBlocks) + return prepareBlocks(groupCtx, cfg, executor, blocks, preparedBlocks, metrics) + }) for workerID := 0; workerID < cfg.workers; workerID++ { workerID := workerID group.Go(func() error { - executor := evmonly.NewExecutor(evmonly.Config{ - MinGasPrice: new(big.Int).Set(cfg.minGasPrice), - DisableGasPriceCheck: cfg.disableGasPriceRule, - OCCWorkers: cfg.executorWorkers, - }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) - return executeBlocks(groupCtx, workerID, executor, blocks, metrics) + return executeBlocks(groupCtx, workerID, executor, preparedBlocks, metrics) }) } @@ -675,6 +699,123 @@ func feedPrebuiltBlocks(ctx context.Context, prebuilt []blockEnvelope, out chan< return nil } +func prepareBlocks( + ctx context.Context, + cfg config, + executor evmonly.PreparedBlockExecutor, + blocks <-chan blockEnvelope, + out chan<- preparedBlockEnvelope, + metrics *loadMetrics, +) error { + unordered := make(chan preparedBlockEnvelope, cfg.queueSize) + group, groupCtx := errgroup.WithContext(ctx) + for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { + workerID := workerID + group.Go(func() error { + for { + select { + case <-groupCtx.Done(): + return nil + case block, ok := <-blocks: + if !ok { + return nil + } + prepared, err := executor.PrepareBlock(groupCtx, block.request) + if err != nil { + metrics.recordPrepareError() + if groupCtx.Err() != nil { + return nil + } + return fmt.Errorf("prepare worker %d prepare block %d: %w", workerID, block.number, err) + } + select { + case unordered <- preparedBlockEnvelope{number: block.number, block: prepared}: + metrics.recordPrepared(len(prepared.Txs)) + case <-groupCtx.Done(): + return nil + } + } + } + }) + } + done := make(chan error, 1) + go func() { + err := group.Wait() + close(unordered) + done <- err + }() + orderErr := forwardPreparedBlocksInOrder(ctx, unordered, out) + workerErr := <-done + if orderErr != nil { + return orderErr + } + return workerErr +} + +func forwardPreparedBlocksInOrder(ctx context.Context, in <-chan preparedBlockEnvelope, out chan<- preparedBlockEnvelope) error { + next := uint64(1) + pending := make(map[uint64]preparedBlockEnvelope) + for { + for { + block, ok := pending[next] + if !ok { + break + } + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + delete(pending, next) + next++ + } + + select { + case <-ctx.Done(): + return nil + case block, ok := <-in: + if !ok { + for len(pending) > 0 { + block, ok := pending[next] + if !ok { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("prepared block stream closed before block %d", next) + } + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + delete(pending, next) + next++ + } + return nil + } + if block.number < next { + return fmt.Errorf("prepared block %d arrived after block %d", block.number, next-1) + } + if block.number == next { + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + next++ + continue + } + if _, exists := pending[block.number]; exists { + return fmt.Errorf("duplicate prepared block %d", block.number) + } + pending[block.number] = block + } + } +} + +func sendPreparedBlock(ctx context.Context, out chan<- preparedBlockEnvelope, block preparedBlockEnvelope) error { + select { + case out <- block: + return nil + case <-ctx.Done(): + return nil + } +} + func maxInt() int { return int(^uint(0) >> 1) } @@ -682,8 +823,8 @@ func maxInt() int { func executeBlocks( ctx context.Context, workerID int, - executor evmonly.BlockExecutor, - blocks <-chan blockEnvelope, + executor evmonly.PreparedBlockExecutor, + blocks <-chan preparedBlockEnvelope, metrics *loadMetrics, ) error { for { @@ -694,7 +835,7 @@ func executeBlocks( if !ok { return nil } - result, err := executor.ExecuteBlock(ctx, block.request) + result, err := executor.ExecutePreparedBlock(ctx, block.block) if err != nil { metrics.recordExecutionError() if ctx.Err() != nil { @@ -1407,9 +1548,12 @@ func (f *appendRLPFile) Close() error { type loadMetrics struct { inputBlocks atomic.Uint64 + preparedBlocks atomic.Uint64 + preparedTxs atomic.Uint64 finishedBlocks atomic.Uint64 finishedTxs atomic.Uint64 gasConsumed atomic.Uint64 + prepareErrors atomic.Uint64 executionErrors atomic.Uint64 occAttempts atomic.Uint64 occFallbacks atomic.Uint64 @@ -1423,9 +1567,12 @@ type loadMetrics struct { sinkQueued atomic.Int64 inputBlocksTotal prometheus.Counter + preparedBlocksTotal prometheus.Counter + preparedTxsTotal prometheus.Counter finishedBlocksTotal prometheus.Counter finishedTxsTotal prometheus.Counter gasConsumedTotal prometheus.Counter + prepareErrorsTotal prometheus.Counter executionErrorsTotal prometheus.Counter occAttemptsTotal prometheus.Counter occFallbacksTotal prometheus.Counter @@ -1441,6 +1588,8 @@ type loadMetrics struct { sinkWriteSecondsTotal *prometheus.CounterVec inputBlockRate prometheus.Gauge + preparedBlockRate prometheus.Gauge + preparedTxRate prometheus.Gauge finishedBlockRate prometheus.Gauge txRate prometheus.Gauge gasRate prometheus.Gauge @@ -1452,9 +1601,12 @@ type loadMetrics struct { type metricsSnapshot struct { at time.Time inputBlocks uint64 + preparedBlocks uint64 + preparedTxs uint64 finishedBlocks uint64 finishedTxs uint64 gasConsumed uint64 + prepareErrors uint64 executionErrors uint64 occAttempts uint64 occFallbacks uint64 @@ -1474,6 +1626,14 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_block_input_total", Help: "Total blocks fed to the EVM-only executor input queue.", }), + preparedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_prepared_total", + Help: "Total blocks decoded and sender-recovered before EVM-only executor execution.", + }), + preparedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_prepared_total", + Help: "Total transactions decoded and sender-recovered before EVM-only executor execution.", + }), finishedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ Name: "evmonly_loadtest_block_finished_total", Help: "Total blocks that finished EVM-only executor execution.", @@ -1486,6 +1646,10 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_gas_consumed_total", Help: "Total EVM gas consumed by finished blocks.", }), + prepareErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_prepare_errors_total", + Help: "Total block preparation errors returned while decoding transactions or recovering senders.", + }), executionErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ Name: "evmonly_loadtest_execution_errors_total", Help: "Total block execution errors returned by the EVM-only executor.", @@ -1538,6 +1702,14 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_block_input_throughput", Help: "Most recent measured block input throughput in blocks per second.", }), + preparedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_prepared_throughput", + Help: "Most recent measured block preparation throughput in blocks per second.", + }), + preparedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_prepared_per_second", + Help: "Most recent measured transaction decode and sender recovery throughput in transactions per second.", + }), finishedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "evmonly_loadtest_block_finished_throughput", Help: "Most recent measured block completion throughput in blocks per second.", @@ -1565,9 +1737,12 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { } registry.MustRegister( m.inputBlocksTotal, + m.preparedBlocksTotal, + m.preparedTxsTotal, m.finishedBlocksTotal, m.finishedTxsTotal, m.gasConsumedTotal, + m.prepareErrorsTotal, m.executionErrorsTotal, m.occAttemptsTotal, m.occFallbacksTotal, @@ -1581,6 +1756,8 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.sinkEnqueueWaitEvents, m.sinkWriteSecondsTotal, m.inputBlockRate, + m.preparedBlockRate, + m.preparedTxRate, m.finishedBlockRate, m.txRate, m.gasRate, @@ -1596,6 +1773,18 @@ func (m *loadMetrics) recordInput() { m.inputBlocksTotal.Inc() } +func (m *loadMetrics) recordPrepared(txCount int) { + m.preparedBlocks.Add(1) + m.preparedTxs.Add(uint64(txCount)) + m.preparedBlocksTotal.Inc() + m.preparedTxsTotal.Add(float64(txCount)) +} + +func (m *loadMetrics) recordPrepareError() { + m.prepareErrors.Add(1) + m.prepareErrorsTotal.Inc() +} + func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64, occStats evmonly.OCCStats) { m.finishedBlocks.Add(1) m.finishedTxs.Add(uint64(txCount)) @@ -1690,9 +1879,12 @@ func (m *loadMetrics) snapshot() metricsSnapshot { return metricsSnapshot{ at: time.Now(), inputBlocks: m.inputBlocks.Load(), + preparedBlocks: m.preparedBlocks.Load(), + preparedTxs: m.preparedTxs.Load(), finishedBlocks: m.finishedBlocks.Load(), finishedTxs: m.finishedTxs.Load(), gasConsumed: m.gasConsumed.Load(), + prepareErrors: m.prepareErrors.Load(), executionErrors: m.executionErrors.Load(), occAttempts: m.occAttempts.Load(), occFallbacks: m.occFallbacks.Load(), @@ -1713,17 +1905,21 @@ func (m *loadMetrics) setRates(prev, curr metricsSnapshot, queued int) { return } inputRate := float64(curr.inputBlocks-prev.inputBlocks) / elapsed + preparedRate := float64(curr.preparedBlocks-prev.preparedBlocks) / elapsed + preparedTxRate := float64(curr.preparedTxs-prev.preparedTxs) / elapsed finishedRate := float64(curr.finishedBlocks-prev.finishedBlocks) / elapsed txRate := float64(curr.finishedTxs-prev.finishedTxs) / elapsed gasRate := float64(curr.gasConsumed-prev.gasConsumed) / elapsed m.inputBlockRate.Set(inputRate) + m.preparedBlockRate.Set(preparedRate) + m.preparedTxRate.Set(preparedTxRate) m.finishedBlockRate.Set(finishedRate) m.txRate.Set(txRate) m.gasRate.Set(gasRate) m.queuedBlocks.Set(float64(queued)) } -func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetrics, blocks <-chan blockEnvelope) { +func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetrics, blocks <-chan preparedBlockEnvelope) { if interval == 0 { <-ctx.Done() return @@ -1748,8 +1944,10 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) fmt.Printf( - "input_blocks/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_enqueued=%d sink_written=%d)\n", + "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_enqueued=%d sink_written=%d)\n", float64(curr.inputBlocks-prev.inputBlocks)/elapsed, + float64(curr.preparedBlocks-prev.preparedBlocks)/elapsed, + float64(curr.preparedTxs-prev.preparedTxs)/elapsed, float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, float64(curr.finishedTxs-prev.finishedTxs)/elapsed, float64(curr.gasConsumed-prev.gasConsumed)/elapsed, @@ -1758,9 +1956,12 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric sinkWaitSeconds/elapsed, sinkWriteSeconds/elapsed, curr.inputBlocks, + curr.preparedBlocks, + curr.preparedTxs, curr.finishedBlocks, curr.finishedTxs, curr.gasConsumed, + curr.prepareErrors, curr.executionErrors, curr.occAttempts, curr.occFallbacks, @@ -1779,12 +1980,15 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { elapsed = 1 } fmt.Printf( - "complete elapsed=%s input_blocks=%d finished_blocks=%d txs=%d gas=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s avg_input_blocks/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", snapshot.at.Sub(startedAt).Round(time.Millisecond), snapshot.inputBlocks, + snapshot.preparedBlocks, + snapshot.preparedTxs, snapshot.finishedBlocks, snapshot.finishedTxs, snapshot.gasConsumed, + snapshot.prepareErrors, snapshot.executionErrors, snapshot.occAttempts, snapshot.occFallbacks, @@ -1797,6 +2001,8 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { snapshot.sinkWaitEvents, time.Duration(snapshot.sinkWriteNanos).Round(time.Microsecond), float64(snapshot.inputBlocks)/elapsed, + float64(snapshot.preparedBlocks)/elapsed, + float64(snapshot.preparedTxs)/elapsed, float64(snapshot.finishedBlocks)/elapsed, float64(snapshot.finishedTxs)/elapsed, float64(snapshot.gasConsumed)/elapsed, From 1390018f98064592e0522b225b972e31d8d90eeb Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 17:53:11 +0800 Subject: [PATCH 14/34] add loadtest worker pinning controls --- giga/evmonly/cmd/evmonly-loadtest/README.md | 6 ++++ giga/evmonly/cmd/evmonly-loadtest/main.go | 28 ++++++++++++++++ .../evmonly-loadtest/worker_affinity_linux.go | 32 +++++++++++++++++++ .../evmonly-loadtest/worker_affinity_other.go | 9 ++++++ 4 files changed, 75 insertions(+) create mode 100644 giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index fb0185bcde..0219fec23c 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -70,6 +70,12 @@ Useful knobs: is `min(12, GOMAXPROCS)`, following the `sei-v3` OCC worker default. - `--prepare-workers`: parallel stateless preparation workers used for transaction RLP decode and sender recovery. The default is `GOMAXPROCS`. +- `--pin-workers`: on Linux, lock prepare and OCC workers to OS threads and + pin them to CPUs. The default is disabled. +- `--prepare-cpu-offset`: first CPU used by pinned prepare workers. The default + is `0`. +- `--executor-cpu-offset`: first CPU used by pinned OCC workers. The default + `-1` starts after the prepare-worker range. - `--builders`: parallel block builders used to keep the input queue full. The default is `GOMAXPROCS`. - `--queue-size`: buffered raw and prepared blocks. The default is `64`. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 4ea5b85383..7b83856043 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -66,8 +66,10 @@ type config struct { queueSize int builders int prepareWorkers int + prepareCPUOffset int workers int executorWorkers int + executorCPUOffset int targetBlocksPerSec float64 reportInterval time.Duration metricsAddr string @@ -92,6 +94,7 @@ type config struct { fixedRecipient *common.Address disableGasPriceRule bool prebuildBlocks bool + pinWorkers bool } type blockEnvelope struct { @@ -135,8 +138,10 @@ func parseConfig(args []string) (config, error) { fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") fs.IntVar(&cfg.prepareWorkers, "prepare-workers", defaultPrepareWorkers(), "parallel block preparation workers for transaction decode and sender recovery") + fs.IntVar(&cfg.prepareCPUOffset, "prepare-cpu-offset", 0, "first CPU index for pinned prepare workers when --pin-workers is set") fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") + fs.IntVar(&cfg.executorCPUOffset, "executor-cpu-offset", -1, "first CPU index for pinned OCC workers when --pin-workers is set; -1 defaults to prepare-cpu-offset+prepare-workers") fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") @@ -153,6 +158,7 @@ func parseConfig(args []string) (config, error) { fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") fs.BoolVar(&cfg.prebuildBlocks, "prebuild-blocks", false, "generate all bounded blocks before starting executor workers") + fs.BoolVar(&cfg.pinWorkers, "pin-workers", false, "lock prepare and OCC workers to OS threads and pin them to CPUs on Linux") if err := fs.Parse(args); err != nil { return config{}, err @@ -213,12 +219,21 @@ func parseConfig(args []string) (config, error) { if cfg.prepareWorkers <= 0 { return config{}, fmt.Errorf("prepare-workers must be positive") } + if cfg.prepareCPUOffset < 0 { + return config{}, fmt.Errorf("prepare-cpu-offset must be non-negative") + } if cfg.workers <= 0 { return config{}, fmt.Errorf("workers must be positive") } if cfg.executorWorkers <= 0 { return config{}, fmt.Errorf("executor-workers must be positive") } + if cfg.executorCPUOffset < -1 { + return config{}, fmt.Errorf("executor-cpu-offset must be non-negative or -1") + } + if cfg.executorCPUOffset == -1 { + cfg.executorCPUOffset = cfg.prepareCPUOffset + cfg.prepareWorkers + } if cfg.targetBlocksPerSec < 0 { return config{}, fmt.Errorf("target-blocks-per-sec must be non-negative") } @@ -522,7 +537,10 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, + PinOCCWorkers: cfg.pinWorkers, + OCCWorkerCPUOffset: cfg.executorCPUOffset, }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) + defer executor.Close() group.Go(func() error { defer close(blocks) return produceBlocks(groupCtx, cfg, workload, blocks, metrics) @@ -580,7 +598,10 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, + PinOCCWorkers: cfg.pinWorkers, + OCCWorkerCPUOffset: cfg.executorCPUOffset, }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) + defer executor.Close() group.Go(func() error { defer close(blocks) return feedPrebuiltBlocks(groupCtx, prebuilt, blocks, metrics) @@ -712,6 +733,13 @@ func prepareBlocks( for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { workerID := workerID group.Go(func() error { + if cfg.pinWorkers { + unlock, err := pinCurrentWorkerThread(cfg.prepareCPUOffset + workerID) + if err != nil { + return fmt.Errorf("pin prepare worker %d: %w", workerID, err) + } + defer unlock() + } for { select { case <-groupCtx.Done(): diff --git a/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go b/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go new file mode 100644 index 0000000000..8619129a4c --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go @@ -0,0 +1,32 @@ +//go:build linux + +package main + +import ( + "fmt" + "runtime" + + "golang.org/x/sys/unix" +) + +func pinCurrentWorkerThread(cpu int) (func(), error) { + runtime.LockOSThread() + unlock := runtime.UnlockOSThread + numCPU := runtime.NumCPU() + if numCPU <= 0 { + unlock() + return func() {}, fmt.Errorf("runtime reported no CPUs") + } + if cpu < 0 { + unlock() + return func() {}, fmt.Errorf("CPU index must be non-negative") + } + cpu %= numCPU + var set unix.CPUSet + set.Set(cpu) + if err := unix.SchedSetaffinity(0, &set); err != nil { + unlock() + return func() {}, err + } + return unlock, nil +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go b/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go new file mode 100644 index 0000000000..7a4f7fed74 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package main + +import "fmt" + +func pinCurrentWorkerThread(int) (func(), error) { + return func() {}, fmt.Errorf("worker CPU pinning is only supported on linux") +} From 4d23269634c7d90d1a01daaba39a494ef2a254db Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 20:37:09 +0800 Subject: [PATCH 15/34] remove loadtest worker pinning controls --- giga/evmonly/cmd/evmonly-loadtest/README.md | 6 ---- giga/evmonly/cmd/evmonly-loadtest/main.go | 26 --------------- .../evmonly-loadtest/worker_affinity_linux.go | 32 ------------------- .../evmonly-loadtest/worker_affinity_other.go | 9 ------ 4 files changed, 73 deletions(-) delete mode 100644 giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go delete mode 100644 giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 0219fec23c..fb0185bcde 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -70,12 +70,6 @@ Useful knobs: is `min(12, GOMAXPROCS)`, following the `sei-v3` OCC worker default. - `--prepare-workers`: parallel stateless preparation workers used for transaction RLP decode and sender recovery. The default is `GOMAXPROCS`. -- `--pin-workers`: on Linux, lock prepare and OCC workers to OS threads and - pin them to CPUs. The default is disabled. -- `--prepare-cpu-offset`: first CPU used by pinned prepare workers. The default - is `0`. -- `--executor-cpu-offset`: first CPU used by pinned OCC workers. The default - `-1` starts after the prepare-worker range. - `--builders`: parallel block builders used to keep the input queue full. The default is `GOMAXPROCS`. - `--queue-size`: buffered raw and prepared blocks. The default is `64`. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 7b83856043..d60e10846c 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -66,10 +66,8 @@ type config struct { queueSize int builders int prepareWorkers int - prepareCPUOffset int workers int executorWorkers int - executorCPUOffset int targetBlocksPerSec float64 reportInterval time.Duration metricsAddr string @@ -94,7 +92,6 @@ type config struct { fixedRecipient *common.Address disableGasPriceRule bool prebuildBlocks bool - pinWorkers bool } type blockEnvelope struct { @@ -138,10 +135,8 @@ func parseConfig(args []string) (config, error) { fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") fs.IntVar(&cfg.prepareWorkers, "prepare-workers", defaultPrepareWorkers(), "parallel block preparation workers for transaction decode and sender recovery") - fs.IntVar(&cfg.prepareCPUOffset, "prepare-cpu-offset", 0, "first CPU index for pinned prepare workers when --pin-workers is set") fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") - fs.IntVar(&cfg.executorCPUOffset, "executor-cpu-offset", -1, "first CPU index for pinned OCC workers when --pin-workers is set; -1 defaults to prepare-cpu-offset+prepare-workers") fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") @@ -158,7 +153,6 @@ func parseConfig(args []string) (config, error) { fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") fs.BoolVar(&cfg.prebuildBlocks, "prebuild-blocks", false, "generate all bounded blocks before starting executor workers") - fs.BoolVar(&cfg.pinWorkers, "pin-workers", false, "lock prepare and OCC workers to OS threads and pin them to CPUs on Linux") if err := fs.Parse(args); err != nil { return config{}, err @@ -219,21 +213,12 @@ func parseConfig(args []string) (config, error) { if cfg.prepareWorkers <= 0 { return config{}, fmt.Errorf("prepare-workers must be positive") } - if cfg.prepareCPUOffset < 0 { - return config{}, fmt.Errorf("prepare-cpu-offset must be non-negative") - } if cfg.workers <= 0 { return config{}, fmt.Errorf("workers must be positive") } if cfg.executorWorkers <= 0 { return config{}, fmt.Errorf("executor-workers must be positive") } - if cfg.executorCPUOffset < -1 { - return config{}, fmt.Errorf("executor-cpu-offset must be non-negative or -1") - } - if cfg.executorCPUOffset == -1 { - cfg.executorCPUOffset = cfg.prepareCPUOffset + cfg.prepareWorkers - } if cfg.targetBlocksPerSec < 0 { return config{}, fmt.Errorf("target-blocks-per-sec must be non-negative") } @@ -537,8 +522,6 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, - PinOCCWorkers: cfg.pinWorkers, - OCCWorkerCPUOffset: cfg.executorCPUOffset, }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) defer executor.Close() group.Go(func() error { @@ -598,8 +581,6 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, - PinOCCWorkers: cfg.pinWorkers, - OCCWorkerCPUOffset: cfg.executorCPUOffset, }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) defer executor.Close() group.Go(func() error { @@ -733,13 +714,6 @@ func prepareBlocks( for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { workerID := workerID group.Go(func() error { - if cfg.pinWorkers { - unlock, err := pinCurrentWorkerThread(cfg.prepareCPUOffset + workerID) - if err != nil { - return fmt.Errorf("pin prepare worker %d: %w", workerID, err) - } - defer unlock() - } for { select { case <-groupCtx.Done(): diff --git a/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go b/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go deleted file mode 100644 index 8619129a4c..0000000000 --- a/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_linux.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build linux - -package main - -import ( - "fmt" - "runtime" - - "golang.org/x/sys/unix" -) - -func pinCurrentWorkerThread(cpu int) (func(), error) { - runtime.LockOSThread() - unlock := runtime.UnlockOSThread - numCPU := runtime.NumCPU() - if numCPU <= 0 { - unlock() - return func() {}, fmt.Errorf("runtime reported no CPUs") - } - if cpu < 0 { - unlock() - return func() {}, fmt.Errorf("CPU index must be non-negative") - } - cpu %= numCPU - var set unix.CPUSet - set.Set(cpu) - if err := unix.SchedSetaffinity(0, &set); err != nil { - unlock() - return func() {}, err - } - return unlock, nil -} diff --git a/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go b/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go deleted file mode 100644 index 7a4f7fed74..0000000000 --- a/giga/evmonly/cmd/evmonly-loadtest/worker_affinity_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !linux - -package main - -import "fmt" - -func pinCurrentWorkerThread(int) (func(), error) { - return func() {}, fmt.Errorf("worker CPU pinning is only supported on linux") -} From 1f295696d65d68d972efa34f6ef0d1b93c6086e9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Jul 2026 21:00:13 +0800 Subject: [PATCH 16/34] reuse loadtest block results --- giga/evmonly/cmd/evmonly-loadtest/README.md | 3 + giga/evmonly/cmd/evmonly-loadtest/main.go | 92 ++++++++++++++++++- .../evmonly/cmd/evmonly-loadtest/main_test.go | 41 +++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index fb0185bcde..351751a32c 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -73,6 +73,9 @@ Useful knobs: - `--builders`: parallel block builders used to keep the input queue full. The default is `GOMAXPROCS`. - `--queue-size`: buffered raw and prepared blocks. The default is `64`. +- `--result-pool-size`: reusable executor result slots. The default `0` + sizes the pool for in-flight executor and async sink results; negative + disables result pooling. - `--target-blocks-per-sec`: cap block input rate. The default `0` feeds as fast as block generation and the queue allow. - `--prebuild-blocks`: generate all bounded blocks before starting executor diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index d60e10846c..73b8238500 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -56,6 +56,7 @@ const ( workloadERC20Transfer = "erc20-transfer" resultSinkDiscard = "discard" resultSinkFile = "file" + resultSinkBlock = "block" resultSinkChangeSet = "changeset" resultSinkReceipts = "receipts" ) @@ -72,6 +73,7 @@ type config struct { reportInterval time.Duration metricsAddr string resultSink string + resultPoolSize int persistDir string persistSync bool persistBufferSize int @@ -141,6 +143,7 @@ func parseConfig(args []string) (config, error) { fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") + fs.IntVar(&cfg.resultPoolSize, "result-pool-size", 0, "pooled executor BlockResult slots; 0 sizes for in-flight sink results, negative disables pooling") fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files") fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") @@ -238,6 +241,11 @@ func parseConfig(args []string) (config, error) { if cfg.persistQueueSize == 0 { cfg.persistQueueSize = 2 * cfg.queueSize } + if cfg.resultPoolSize < 0 { + cfg.resultPoolSize = 0 + } else if cfg.resultPoolSize == 0 { + cfg.resultPoolSize = defaultResultPoolSize(cfg) + } if cfg.resultSink == resultSinkFile && strings.TrimSpace(cfg.persistDir) == "" { return config{}, fmt.Errorf("persist-dir is required when result-sink=file") } @@ -277,6 +285,14 @@ func defaultExecutorWorkers() int { return workers } +func defaultResultPoolSize(cfg config) int { + size := cfg.workers + 1 + if cfg.resultSink == resultSinkFile { + size += cfg.persistQueueSize + } + return size +} + func parsePositiveBig(name, raw string) (*big.Int, error) { v, err := parseBig(name, raw) if err != nil { @@ -522,6 +538,7 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, + BlockResultPoolSize: cfg.resultPoolSize, }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) defer executor.Close() group.Go(func() error { @@ -581,6 +598,7 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa MinGasPrice: new(big.Int).Set(cfg.minGasPrice), DisableGasPriceCheck: cfg.disableGasPriceRule, OCCWorkers: cfg.executorWorkers, + BlockResultPoolSize: cfg.resultPoolSize, }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) defer executor.Close() group.Go(func() error { @@ -846,6 +864,7 @@ func executeBlocks( return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) } metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) + result.Release() } } } @@ -1203,6 +1222,7 @@ type resultSinks struct { } var _ evmonly.ResultSink = (*resultSinks)(nil) +var _ evmonly.BlockResultSink = (*resultSinks)(nil) func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { switch cfg.resultSink { @@ -1226,6 +1246,20 @@ func (s *resultSinks) StoreReceipts(ctx context.Context, height uint64, receipts return s.receipts.StoreReceipts(ctx, height, receipts) } +func (s *resultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { + if sink, ok := s.changeSets.(evmonly.BlockResultSink); ok { + return sink.StoreBlockResult(ctx, height, result, release) + } + if err := s.StoreChangeSet(ctx, height, result.ChangeSet); err != nil { + return err + } + if err := s.StoreReceipts(ctx, height, result.Receipts); err != nil { + return err + } + release() + return nil +} + func (s *resultSinks) Close() error { var closeErr error if s.close == nil { @@ -1346,9 +1380,11 @@ func (s *fileResultSinks) WriteRecord(kind string, height uint64, value any) err } type resultSinkRecord struct { - kind string - height uint64 - value any + kind string + height uint64 + value any + result *evmonly.BlockResult + release func() } type asyncFileResultSinks struct { @@ -1392,6 +1428,15 @@ func (s *asyncFileResultSinks) StoreReceipts(ctx context.Context, height uint64, }) } +func (s *asyncFileResultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { + return s.enqueue(ctx, resultSinkRecord{ + kind: resultSinkBlock, + height: height, + result: result, + release: release, + }) +} + func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRecord) error { if err := s.getErr(); err != nil { return err @@ -1428,6 +1473,12 @@ func (s *asyncFileResultSinks) recordEnqueued(kind string) { if s.metrics == nil { return } + if kind == resultSinkBlock { + s.metrics.recordSinkEnqueued(resultSinkChangeSet) + s.metrics.recordSinkEnqueued(resultSinkReceipts) + s.metrics.setSinkQueued(len(s.records)) + return + } s.metrics.recordSinkEnqueued(kind) s.metrics.setSinkQueued(len(s.records)) } @@ -1438,10 +1489,13 @@ func (s *asyncFileResultSinks) run() { if s.metrics != nil { s.metrics.setSinkQueued(len(s.records)) } - if err := s.files.WriteRecord(record.kind, record.height, record.value); err != nil { + if err := s.writeRecord(record); err != nil { + record.releaseResult() s.setErr(err) + s.releaseQueuedResults() return } + record.releaseResult() if s.metrics != nil { s.metrics.setSinkQueued(len(s.records)) } @@ -1451,6 +1505,36 @@ func (s *asyncFileResultSinks) run() { } } +func (s *asyncFileResultSinks) releaseQueuedResults() { + for { + select { + case record, ok := <-s.records: + if !ok { + return + } + record.releaseResult() + default: + return + } + } +} + +func (s *asyncFileResultSinks) writeRecord(record resultSinkRecord) error { + if record.kind == resultSinkBlock { + if err := s.files.WriteRecord(resultSinkChangeSet, record.height, record.result.ChangeSet); err != nil { + return err + } + return s.files.WriteRecord(resultSinkReceipts, record.height, record.result.Receipts) + } + return s.files.WriteRecord(record.kind, record.height, record.value) +} + +func (r resultSinkRecord) releaseResult() { + if r.release != nil { + r.release() + } +} + func (s *asyncFileResultSinks) Close() error { s.close.Do(func() { close(s.records) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index d719738706..9ead8d07e8 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -8,6 +8,7 @@ import ( "math/big" "os" "path/filepath" + "sync/atomic" "testing" "time" @@ -225,6 +226,46 @@ func TestAsyncFileResultSinkReportsMetrics(t *testing.T) { require.Greater(t, snapshot.sinkBytes, uint64(0)) } +func TestAsyncFileResultSinkStoresBlockResultAndReleases(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--result-sink=file", + "--persist-dir=" + dir, + "--persist-queue-size=1", + }) + require.NoError(t, err) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) + require.NoError(t, err) + + result := &evmonly.BlockResult{ + ChangeSet: evmonly.StateChangeSet{ + Balances: []evmonly.BalanceChange{{ + Address: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Balance: big.NewInt(7), + }}, + }, + Receipts: ethtypes.Receipts{{ + Type: ethtypes.LegacyTxType, + Status: ethtypes.ReceiptStatusSuccessful, + TxHash: common.HexToHash("0x02"), + }}, + } + var released atomic.Bool + require.NoError(t, sinks.StoreBlockResult(context.Background(), 1, result, func() { + released.Store(true) + })) + require.Eventually(t, func() bool { + return metrics.snapshot().sinkWritten == 2 && released.Load() + }, time.Second, time.Millisecond) + require.NoError(t, sinks.Close()) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(2), snapshot.sinkEnqueued) + require.Equal(t, uint64(2), snapshot.sinkWritten) +} + func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ From bbe827eff1dc8fece77707790436f18dab84a475 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 22 Jul 2026 18:01:05 +0800 Subject: [PATCH 17/34] fix loadtest result sink interface after rebase --- giga/evmonly/cmd/evmonly-loadtest/main.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 73b8238500..da5d931d53 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -1222,7 +1222,6 @@ type resultSinks struct { } var _ evmonly.ResultSink = (*resultSinks)(nil) -var _ evmonly.BlockResultSink = (*resultSinks)(nil) func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { switch cfg.resultSink { @@ -1247,7 +1246,7 @@ func (s *resultSinks) StoreReceipts(ctx context.Context, height uint64, receipts } func (s *resultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { - if sink, ok := s.changeSets.(evmonly.BlockResultSink); ok { + if sink, ok := s.changeSets.(evmonly.ResultSink); ok { return sink.StoreBlockResult(ctx, height, result, release) } if err := s.StoreChangeSet(ctx, height, result.ChangeSet); err != nil { From 4f2763f466d223d50b7aa146db20f381b666e31c Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 27 Jul 2026 11:55:04 +0800 Subject: [PATCH 18/34] set loadtest blob base fee after rebase --- giga/evmonly/cmd/evmonly-loadtest/main.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index da5d931d53..66389ad692 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -1033,15 +1033,16 @@ func erc20BalanceSlot(owner common.Address) common.Hash { func blockContext(cfg config, number uint64) evmonly.BlockContext { return evmonly.BlockContext{ - Number: number, - Time: uint64(time.Now().Unix()), - GasLimit: cfg.blockGasLimit, - ChainID: new(big.Int).Set(cfg.chainID), - BaseFee: big.NewInt(0), - Coinbase: cfg.coinbase, - ParentHash: hashFromSeed("sei-evmonly-loadtest-parent", number-1), - BlockHash: hashFromSeed("sei-evmonly-loadtest-block", number), - PrevRandao: hashFromSeed("sei-evmonly-loadtest-randao", number), + Number: number, + Time: uint64(time.Now().Unix()), + GasLimit: cfg.blockGasLimit, + ChainID: new(big.Int).Set(cfg.chainID), + BaseFee: big.NewInt(0), + BlobBaseFee: big.NewInt(0), + Coinbase: cfg.coinbase, + ParentHash: hashFromSeed("sei-evmonly-loadtest-parent", number-1), + BlockHash: hashFromSeed("sei-evmonly-loadtest-block", number), + PrevRandao: hashFromSeed("sei-evmonly-loadtest-randao", number), } } From 74905ee16aca1c7d6480f81104fe25cf172adee9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 28 Jul 2026 11:46:30 +0800 Subject: [PATCH 19/34] mark loadtest state reader concurrent --- giga/evmonly/cmd/evmonly-loadtest/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 66389ad692..572008e9e9 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -1083,6 +1083,7 @@ type generatedState struct { } var _ evmonly.StateReader = (*generatedState)(nil) +var _ evmonly.ConcurrentStateReader = (*generatedState)(nil) var frozenZeroBalance = new(big.Int) @@ -1099,6 +1100,8 @@ func (s *generatedState) Freeze() { s.frozen.Store(true) } +func (s *generatedState) ConcurrentReads() {} + func (s *generatedState) GetBalance(addr common.Address) *big.Int { if s.frozen.Load() { if balance, ok := s.balances[addr]; ok && balance != nil { From 0d63fb44ea22f355481e961aefce977fc93de296 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 28 Jul 2026 12:04:20 +0800 Subject: [PATCH 20/34] add evmonly loadtest recipient conflicts --- giga/evmonly/cmd/evmonly-loadtest/README.md | 23 ++- giga/evmonly/cmd/evmonly-loadtest/main.go | 175 +++++++++++------- .../evmonly/cmd/evmonly-loadtest/main_test.go | 53 +++++- 3 files changed, 187 insertions(+), 64 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 351751a32c..08ba258bf1 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -7,7 +7,9 @@ It currently generates pure EVM legacy transfer transactions and ERC20 transfer transactions. Each generated sender account has exactly one nonce-0 transaction and is funded in the command's in-memory genesis state before its block is queued. Recipients are unique by default so the workloads exercise the optimistic -no-overlap case; pass `--recipient=0x...` to force a single recipient. +no-overlap case. Pass `--recipient-conflict-rate=<0..1>` to pair that fraction +of each block's transactions onto shared recipients, or pass `--recipient=0x...` +to force all transactions to a single recipient. Run a bounded test: @@ -37,6 +39,22 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ --queue-size=512 ``` +Example conflict run: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --blocks=400 \ + --txs-per-block=5000 \ + --builders=16 \ + --workers=1 \ + --executor-workers=12 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 \ + --recipient-conflict-rate=0.10 +``` + To isolate executor throughput from block generation, prebuild a bounded run before starting the prepare/recover and executor workers: @@ -85,6 +103,9 @@ Useful knobs: - `--report-interval`: stdout rate reporting interval. The default is `5s`. - `--gas-price-wei`, `--min-gas-price-wei`, `--sender-balance-wei`, `--transfer-value-wei`: transaction economics for the generated accounts. +- `--recipient-conflict-rate`: fraction of each block's transactions that are + paired onto shared recipients; `0` keeps recipients unique and `1` pairs all + possible transactions. - `--workload`: workload type, either `transfer` or `erc20-transfer`. The command reports these saturation signals on stdout and at `/metrics`: diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 572008e9e9..7fbc3759f8 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -62,38 +62,39 @@ const ( ) type config struct { - blocks uint64 - txsPerBlock int - queueSize int - builders int - prepareWorkers int - workers int - executorWorkers int - targetBlocksPerSec float64 - reportInterval time.Duration - metricsAddr string - resultSink string - resultPoolSize int - persistDir string - persistSync bool - persistBufferSize int - persistQueueSize int - cpuProfile string - heapProfile string - traceProfile string - workload string - chainID *big.Int - gasPrice *big.Int - minGasPrice *big.Int - senderBalance *big.Int - transferValue *big.Int - txGasLimit uint64 - blockGasLimit uint64 - coinbase common.Address - erc20Contract common.Address - fixedRecipient *common.Address - disableGasPriceRule bool - prebuildBlocks bool + blocks uint64 + txsPerBlock int + queueSize int + builders int + prepareWorkers int + workers int + executorWorkers int + targetBlocksPerSec float64 + reportInterval time.Duration + metricsAddr string + resultSink string + resultPoolSize int + persistDir string + persistSync bool + persistBufferSize int + persistQueueSize int + cpuProfile string + heapProfile string + traceProfile string + workload string + chainID *big.Int + gasPrice *big.Int + minGasPrice *big.Int + senderBalance *big.Int + transferValue *big.Int + txGasLimit uint64 + blockGasLimit uint64 + coinbase common.Address + erc20Contract common.Address + fixedRecipient *common.Address + recipientConflictRate float64 + disableGasPriceRule bool + prebuildBlocks bool } type blockEnvelope struct { @@ -131,6 +132,7 @@ func parseConfig(args []string) (config, error) { coinbase := fs.String("coinbase", defaultCoinbaseAddress, "block coinbase address") erc20Contract := fs.String("erc20-contract", defaultERC20Contract, "EVM address for the generated ERC20 transfer contract") recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") + fs.Float64Var(&cfg.recipientConflictRate, "recipient-conflict-rate", 0, "fraction [0,1] of transactions per block paired onto shared recipients; 0 keeps recipients unique") fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to feed; 0 runs until interrupted") fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") @@ -225,6 +227,12 @@ func parseConfig(args []string) (config, error) { if cfg.targetBlocksPerSec < 0 { return config{}, fmt.Errorf("target-blocks-per-sec must be non-negative") } + if cfg.recipientConflictRate < 0 || cfg.recipientConflictRate > 1 { + return config{}, fmt.Errorf("recipient-conflict-rate must be between 0 and 1") + } + if cfg.fixedRecipient != nil && cfg.recipientConflictRate != 0 { + return config{}, fmt.Errorf("recipient cannot be combined with recipient-conflict-rate") + } if cfg.reportInterval < 0 { return config{}, fmt.Errorf("report-interval must be non-negative") } @@ -870,17 +878,19 @@ func executeBlocks( } type transferWorkload struct { - cfg config - state *generatedState - signer ethtypes.Signer - accountCursor atomic.Uint64 + cfg config + state *generatedState + signer ethtypes.Signer + conflictParticipants int + accountCursor atomic.Uint64 } func newTransferWorkload(cfg config, state *generatedState) *transferWorkload { return &transferWorkload{ - cfg: cfg, - state: state, - signer: ethtypes.LatestSignerForChainID(cfg.chainID), + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.chainID), + conflictParticipants: recipientConflictParticipants(cfg.txsPerBlock, cfg.recipientConflictRate), } } @@ -893,7 +903,8 @@ func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmon default: } accountIndex := w.accountCursor.Add(1) - raw, sender, err := w.buildTransferTx(accountIndex) + recipient := w.recipient(number, i, accountIndex) + raw, sender, err := w.buildTransferTx(accountIndex, recipient) if err != nil { return evmonly.BlockRequest{}, err } @@ -906,13 +917,12 @@ func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmon }, nil } -func (w *transferWorkload) buildTransferTx(accountIndex uint64) ([]byte, common.Address, error) { +func (w *transferWorkload) buildTransferTx(accountIndex uint64, recipient common.Address) ([]byte, common.Address, error) { key, err := deterministicPrivateKey(accountIndex) if err != nil { return nil, common.Address{}, err } sender := crypto.PubkeyToAddress(key.PublicKey) - recipient := w.recipient(accountIndex) tx := ethtypes.NewTx(ðtypes.LegacyTx{ Nonce: 0, GasPrice: new(big.Int).Set(w.cfg.gasPrice), @@ -931,18 +941,16 @@ func (w *transferWorkload) buildTransferTx(accountIndex uint64) ([]byte, common. return raw, sender, nil } -func (w *transferWorkload) recipient(accountIndex uint64) common.Address { - if w.cfg.fixedRecipient != nil { - return *w.cfg.fixedRecipient - } - return addressFromSeed("sei-evmonly-loadtest-recipient", accountIndex) +func (w *transferWorkload) recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-recipient", "sei-evmonly-loadtest-conflict-recipient", blockNumber, txIndex, accountIndex) } type erc20TransferWorkload struct { - cfg config - state *generatedState - signer ethtypes.Signer - accountCursor atomic.Uint64 + cfg config + state *generatedState + signer ethtypes.Signer + conflictParticipants int + accountCursor atomic.Uint64 } var ( @@ -955,9 +963,10 @@ var ( func newERC20TransferWorkload(cfg config, state *generatedState) *erc20TransferWorkload { state.SetCode(cfg.erc20Contract, erc20TransferRuntimeCode) return &erc20TransferWorkload{ - cfg: cfg, - state: state, - signer: ethtypes.LatestSignerForChainID(cfg.chainID), + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.chainID), + conflictParticipants: recipientConflictParticipants(cfg.txsPerBlock, cfg.recipientConflictRate), } } @@ -970,7 +979,8 @@ func (w *erc20TransferWorkload) buildBlock(ctx context.Context, number uint64) ( default: } accountIndex := w.accountCursor.Add(1) - raw, sender, err := w.buildTransferTx(accountIndex) + recipient := w.recipient(number, i, accountIndex) + raw, sender, err := w.buildTransferTx(accountIndex, recipient) if err != nil { return evmonly.BlockRequest{}, err } @@ -984,13 +994,12 @@ func (w *erc20TransferWorkload) buildBlock(ctx context.Context, number uint64) ( }, nil } -func (w *erc20TransferWorkload) buildTransferTx(accountIndex uint64) ([]byte, common.Address, error) { +func (w *erc20TransferWorkload) buildTransferTx(accountIndex uint64, recipient common.Address) ([]byte, common.Address, error) { key, err := deterministicPrivateKey(accountIndex) if err != nil { return nil, common.Address{}, err } sender := crypto.PubkeyToAddress(key.PublicKey) - recipient := w.recipient(accountIndex) tx := ethtypes.NewTx(ðtypes.LegacyTx{ Nonce: 0, GasPrice: new(big.Int).Set(w.cfg.gasPrice), @@ -1010,11 +1019,8 @@ func (w *erc20TransferWorkload) buildTransferTx(accountIndex uint64) ([]byte, co return raw, sender, nil } -func (w *erc20TransferWorkload) recipient(accountIndex uint64) common.Address { - if w.cfg.fixedRecipient != nil { - return *w.cfg.fixedRecipient - } - return addressFromSeed("sei-evmonly-loadtest-erc20-recipient", accountIndex) +func (w *erc20TransferWorkload) recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-erc20-recipient", "sei-evmonly-loadtest-erc20-conflict-recipient", blockNumber, txIndex, accountIndex) } func erc20TransferCalldata(recipient common.Address, amount *big.Int) []byte { @@ -1031,6 +1037,43 @@ func erc20BalanceSlot(owner common.Address) common.Hash { return crypto.Keccak256Hash(encoded[:]) } +func workloadRecipient(cfg config, conflictParticipants int, uniquePrefix string, conflictPrefix string, blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + if cfg.fixedRecipient != nil { + return *cfg.fixedRecipient + } + if txIndex < conflictParticipants { + return blockScopedAddressFromSeed(conflictPrefix, blockNumber, uint64(txIndex/2)) + } + return addressFromSeed(uniquePrefix, accountIndex) +} + +func recipientConflictParticipants(txsPerBlock int, rate float64) int { + if rate <= 0 || txsPerBlock < 2 { + return 0 + } + if rate >= 1 { + if txsPerBlock%2 == 0 { + return txsPerBlock + } + return txsPerBlock - 1 + } + count := int(math.Round(rate * float64(txsPerBlock))) + if count < 2 { + count = 2 + } + if count > txsPerBlock { + count = txsPerBlock + } + if count%2 != 0 { + if count == txsPerBlock { + count-- + } else { + count++ + } + } + return count +} + func blockContext(cfg config, number uint64) evmonly.BlockContext { return evmonly.BlockContext{ Number: number, @@ -1067,6 +1110,14 @@ func addressFromSeed(prefix string, index uint64) common.Address { return common.BytesToAddress(hash[12:]) } +func blockScopedAddressFromSeed(prefix string, blockNumber uint64, index uint64) common.Address { + var buf [16]byte + binary.BigEndian.PutUint64(buf[:8], blockNumber) + binary.BigEndian.PutUint64(buf[8:], index) + hash := crypto.Keccak256Hash([]byte(prefix), buf[:]) + return common.BytesToAddress(hash[12:]) +} + func hashFromSeed(prefix string, index uint64) common.Hash { var buf [8]byte binary.BigEndian.PutUint64(buf[:], index) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index 9ead8d07e8..c5ebd349a7 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -98,12 +98,50 @@ func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { key, err := deterministicPrivateKey(i) require.NoError(t, err) sender := crypto.PubkeyToAddress(key.PublicKey) - recipient := transferWorkload.recipient(i) + recipient := transferWorkload.recipient(1, int(i-1), i) require.Equal(t, common.Hash{}, state.GetState(cfg.erc20Contract, erc20BalanceSlot(sender))) require.Equal(t, common.BigToHash(cfg.transferValue), state.GetState(cfg.erc20Contract, erc20BalanceSlot(recipient))) } } +func TestTransferWorkloadRecipientConflictRate(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--txs-per-block=4", + "--recipient-conflict-rate=0.5", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + + state := newGeneratedState() + workload := newTransferWorkload(cfg, state) + request, err := workload.buildBlock(context.Background(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: 4, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(context.Background(), request) + require.NoError(t, err) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + require.Greater(t, result.OCCStats.ConflictCount, uint64(0)) + require.Greater(t, result.OCCStats.RerunCount, uint64(0)) + + conflictRecipient := workload.recipient(1, 0, 1) + require.Equal(t, conflictRecipient, workload.recipient(1, 1, 2)) + require.NotEqual(t, conflictRecipient, workload.recipient(1, 2, 3)) + require.NotEqual(t, workload.recipient(1, 2, 3), workload.recipient(1, 3, 4)) + + applyGeneratedStateChangeSet(state, result.ChangeSet) + twoTransfers := new(big.Int).Mul(cfg.transferValue, big.NewInt(2)) + require.Equal(t, twoTransfers, state.GetBalance(conflictRecipient)) + require.Equal(t, cfg.transferValue, state.GetBalance(workload.recipient(1, 2, 3))) + require.Equal(t, cfg.transferValue, state.GetBalance(workload.recipient(1, 3, 4))) +} + func applyGeneratedStateChangeSet(state *generatedState, changeSet evmonly.StateChangeSet) { for _, change := range changeSet.Balances { state.SetBalance(change.Address, change.Balance) @@ -134,6 +172,19 @@ func TestPrebuildBlocksRequiresBoundedRun(t *testing.T) { require.ErrorContains(t, err, "prebuild-blocks requires --blocks > 0") } +func TestRecipientConflictRateValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--recipient-conflict-rate=1.1", + }) + require.ErrorContains(t, err, "recipient-conflict-rate must be between 0 and 1") + + _, err = parseConfig([]string{ + "--recipient=0x0000000000000000000000000000000000000001", + "--recipient-conflict-rate=0.5", + }) + require.ErrorContains(t, err, "recipient cannot be combined with recipient-conflict-rate") +} + func TestRunPrebuiltBlocks(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", From 1d6fe15abca10ee4ab085e5d4d5d3e96a04121d0 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 28 Jul 2026 17:11:15 +0800 Subject: [PATCH 21/34] add snapshot revert loadtest workload --- giga/evmonly/cmd/evmonly-loadtest/README.md | 43 +++- giga/evmonly/cmd/evmonly-loadtest/main.go | 228 +++++++++++++----- .../evmonly/cmd/evmonly-loadtest/main_test.go | 73 ++++++ 3 files changed, 276 insertions(+), 68 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 08ba258bf1..c0c2a26515 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -3,13 +3,15 @@ `evmonly-loadtest` is a standalone executable for feeding synthetic blocks to the EVM-only executor without Cosmos SDK state, mempool, RPC, or persistence. -It currently generates pure EVM legacy transfer transactions and ERC20 transfer -transactions. Each generated sender account has exactly one nonce-0 transaction -and is funded in the command's in-memory genesis state before its block is -queued. Recipients are unique by default so the workloads exercise the optimistic -no-overlap case. Pass `--recipient-conflict-rate=<0..1>` to pair that fraction -of each block's transactions onto shared recipients, or pass `--recipient=0x...` -to force all transactions to a single recipient. +It currently generates pure EVM legacy transfer transactions, ERC20 transfer +transactions, and a contract-call workload that exercises nested StateDB +snapshot/revert behavior. Each generated sender account has exactly one nonce-0 +transaction and is funded in the command's in-memory genesis state before its +block is queued. Recipients are unique by default so the transfer workloads +exercise the optimistic no-overlap case. Pass +`--recipient-conflict-rate=<0..1>` to pair that fraction of each block's +transactions onto shared recipients, or pass `--recipient=0x...` to force all +transactions to a single recipient. Run a bounded test: @@ -55,6 +57,22 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ --recipient-conflict-rate=0.10 ``` +Example snapshot/revert contract-call run: + +```bash +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --metrics-addr= \ + --report-interval=5s \ + --workload=snapshot-revert \ + --blocks=400 \ + --txs-per-block=5000 \ + --builders=16 \ + --workers=1 \ + --executor-workers=12 \ + --gas-price-wei=0 \ + --min-gas-price-wei=0 +``` + To isolate executor throughput from block generation, prebuild a bounded run before starting the prepare/recover and executor workers: @@ -106,7 +124,10 @@ Useful knobs: - `--recipient-conflict-rate`: fraction of each block's transactions that are paired onto shared recipients; `0` keeps recipients unique and `1` pairs all possible transactions. -- `--workload`: workload type, either `transfer` or `erc20-transfer`. +- `--workload`: workload type, either `transfer`, `erc20-transfer`, or + `snapshot-revert`. +- `--snapshot-revert-contract`, `--snapshot-revert-helper`: generated contract + addresses used by `--workload=snapshot-revert`. The command reports these saturation signals on stdout and at `/metrics`: @@ -126,6 +147,6 @@ The executor output is intentionally discarded through mocks: executor `StateChangeSet`. - `discardReceiptSink` sinks Ethereum receipts. -Future workloads should add another workload builder beside `transferWorkload` -and `erc20TransferWorkload`, then reuse the same block -producer/prepare/executor/metrics pipeline. +Future workloads should add another workload builder beside `transferWorkload`, +`erc20TransferWorkload`, and `snapshotRevertWorkload`, then reuse the same +block producer/prepare/executor/metrics pipeline. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 7fbc3759f8..4b4389e864 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -37,64 +37,70 @@ import ( ) const ( - defaultChainID = "713715" - defaultGasPriceWei = "1000000000" - defaultMinGasPriceWei = "1000000000" - defaultSenderBalance = "1000000000000000000" - defaultTransferValue = "1" - defaultERC20Contract = "0x000000000000000000000000000000000000e20c" - defaultMetricsAddr = "127.0.0.1:9698" - defaultReportInterval = 5 * time.Second - defaultQueueSize = 64 - defaultTxGasLimit = 21_000 - defaultERC20TxGasLimit = 100_000 - defaultTxsPerBlock = 1_000 - defaultPersistBuffer = 4 << 20 - defaultWorkerCount = 1 - defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" - workloadTransfer = "transfer" - workloadERC20Transfer = "erc20-transfer" - resultSinkDiscard = "discard" - resultSinkFile = "file" - resultSinkBlock = "block" - resultSinkChangeSet = "changeset" - resultSinkReceipts = "receipts" + defaultChainID = "713715" + defaultGasPriceWei = "1000000000" + defaultMinGasPriceWei = "1000000000" + defaultSenderBalance = "1000000000000000000" + defaultTransferValue = "1" + defaultERC20Contract = "0x000000000000000000000000000000000000e20c" + defaultSnapshotRevertContract = "0x0000000000000000000000000000000000005a90" + defaultSnapshotRevertHelper = "0x0000000000000000000000000000000000005a91" + defaultMetricsAddr = "127.0.0.1:9698" + defaultReportInterval = 5 * time.Second + defaultQueueSize = 64 + defaultTxGasLimit = 21_000 + defaultERC20TxGasLimit = 100_000 + defaultSnapshotRevertTxGasLimit = 100_000 + defaultTxsPerBlock = 1_000 + defaultPersistBuffer = 4 << 20 + defaultWorkerCount = 1 + defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" + workloadTransfer = "transfer" + workloadERC20Transfer = "erc20-transfer" + workloadSnapshotRevert = "snapshot-revert" + resultSinkDiscard = "discard" + resultSinkFile = "file" + resultSinkBlock = "block" + resultSinkChangeSet = "changeset" + resultSinkReceipts = "receipts" ) type config struct { - blocks uint64 - txsPerBlock int - queueSize int - builders int - prepareWorkers int - workers int - executorWorkers int - targetBlocksPerSec float64 - reportInterval time.Duration - metricsAddr string - resultSink string - resultPoolSize int - persistDir string - persistSync bool - persistBufferSize int - persistQueueSize int - cpuProfile string - heapProfile string - traceProfile string - workload string - chainID *big.Int - gasPrice *big.Int - minGasPrice *big.Int - senderBalance *big.Int - transferValue *big.Int - txGasLimit uint64 - blockGasLimit uint64 - coinbase common.Address - erc20Contract common.Address - fixedRecipient *common.Address - recipientConflictRate float64 - disableGasPriceRule bool - prebuildBlocks bool + blocks uint64 + txsPerBlock int + queueSize int + builders int + prepareWorkers int + workers int + executorWorkers int + targetBlocksPerSec float64 + reportInterval time.Duration + metricsAddr string + resultSink string + resultPoolSize int + persistDir string + persistSync bool + persistBufferSize int + persistQueueSize int + cpuProfile string + heapProfile string + traceProfile string + workload string + chainID *big.Int + gasPrice *big.Int + minGasPrice *big.Int + senderBalance *big.Int + transferValue *big.Int + txGasLimit uint64 + blockGasLimit uint64 + coinbase common.Address + erc20Contract common.Address + snapshotRevertContract common.Address + snapshotRevertHelper common.Address + fixedRecipient *common.Address + recipientConflictRate float64 + disableGasPriceRule bool + prebuildBlocks bool } type blockEnvelope struct { @@ -131,6 +137,8 @@ func parseConfig(args []string) (config, error) { transferValue := fs.String("transfer-value-wei", defaultTransferValue, "wei or token units transferred by each generated transaction") coinbase := fs.String("coinbase", defaultCoinbaseAddress, "block coinbase address") erc20Contract := fs.String("erc20-contract", defaultERC20Contract, "EVM address for the generated ERC20 transfer contract") + snapshotRevertContract := fs.String("snapshot-revert-contract", defaultSnapshotRevertContract, "EVM address for the generated snapshot-revert outer contract") + snapshotRevertHelper := fs.String("snapshot-revert-helper", defaultSnapshotRevertHelper, "EVM address for the generated snapshot-revert helper contract") recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") fs.Float64Var(&cfg.recipientConflictRate, "recipient-conflict-rate", 0, "fraction [0,1] of transactions per block paired onto shared recipients; 0 keeps recipients unique") @@ -153,7 +161,7 @@ func parseConfig(args []string) (config, error) { fs.StringVar(&cfg.cpuProfile, "cpu-profile", "", "write Go CPU profile to this file; starts after prebuild when --prebuild-blocks is set") fs.StringVar(&cfg.heapProfile, "heap-profile", "", "write Go heap profile to this file after execution") fs.StringVar(&cfg.traceProfile, "trace-profile", "", "write Go runtime trace to this file; starts after prebuild when --prebuild-blocks is set") - fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer or erc20-transfer") + fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer, erc20-transfer, or snapshot-revert") fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") @@ -193,8 +201,16 @@ func parseConfig(args []string) (config, error) { return config{}, fmt.Errorf("erc20-contract must be a hex EVM address") } cfg.erc20Contract = common.HexToAddress(*erc20Contract) + if !common.IsHexAddress(*snapshotRevertContract) { + return config{}, fmt.Errorf("snapshot-revert-contract must be a hex EVM address") + } + cfg.snapshotRevertContract = common.HexToAddress(*snapshotRevertContract) + if !common.IsHexAddress(*snapshotRevertHelper) { + return config{}, fmt.Errorf("snapshot-revert-helper must be a hex EVM address") + } + cfg.snapshotRevertHelper = common.HexToAddress(*snapshotRevertHelper) cfg.workload = strings.ToLower(strings.TrimSpace(cfg.workload)) - if cfg.workload != workloadTransfer && cfg.workload != workloadERC20Transfer { + if cfg.workload != workloadTransfer && cfg.workload != workloadERC20Transfer && cfg.workload != workloadSnapshotRevert { return config{}, fmt.Errorf("unsupported workload %q", cfg.workload) } txGasLimitSet := false @@ -206,6 +222,9 @@ func parseConfig(args []string) (config, error) { if cfg.workload == workloadERC20Transfer && !txGasLimitSet { cfg.txGasLimit = defaultERC20TxGasLimit } + if cfg.workload == workloadSnapshotRevert && !txGasLimitSet { + cfg.txGasLimit = defaultSnapshotRevertTxGasLimit + } if cfg.txsPerBlock <= 0 { return config{}, fmt.Errorf("txs-per-block must be positive") } @@ -233,6 +252,15 @@ func parseConfig(args []string) (config, error) { if cfg.fixedRecipient != nil && cfg.recipientConflictRate != 0 { return config{}, fmt.Errorf("recipient cannot be combined with recipient-conflict-rate") } + if cfg.workload == workloadSnapshotRevert && cfg.fixedRecipient != nil { + return config{}, fmt.Errorf("recipient is not supported with snapshot-revert workload") + } + if cfg.workload == workloadSnapshotRevert && cfg.recipientConflictRate != 0 { + return config{}, fmt.Errorf("recipient-conflict-rate is not supported with snapshot-revert workload") + } + if cfg.workload == workloadSnapshotRevert && cfg.snapshotRevertContract == cfg.snapshotRevertHelper { + return config{}, fmt.Errorf("snapshot-revert-contract and snapshot-revert-helper must differ") + } if cfg.reportInterval < 0 { return config{}, fmt.Errorf("report-interval must be non-negative") } @@ -520,6 +548,8 @@ func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { return newTransferWorkload(cfg, state), nil case workloadERC20Transfer: return newERC20TransferWorkload(cfg, state), nil + case workloadSnapshotRevert: + return newSnapshotRevertWorkload(cfg, state), nil default: return nil, fmt.Errorf("unsupported workload %q", cfg.workload) } @@ -1023,6 +1053,80 @@ func (w *erc20TransferWorkload) recipient(blockNumber uint64, txIndex int, accou return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-erc20-recipient", "sei-evmonly-loadtest-erc20-conflict-recipient", blockNumber, txIndex, accountIndex) } +type snapshotRevertWorkload struct { + cfg config + state *generatedState + signer ethtypes.Signer + accountCursor atomic.Uint64 +} + +var ( + // The outer runtime stores 1 at storage[CALLER], delegatecalls the helper + // address encoded in calldata, ignores the helper's reverted status, and + // returns successfully. + snapshotRevertOuterRuntimeCode = common.FromHex("0x6001335560006000600060006000355af450600160005260206000f3") + // The helper runtime runs under DELEGATECALL, overwrites storage[CALLER] + // with 2 in the outer storage context, then executes REVERT. + snapshotRevertHelperRuntimeCode = common.FromHex("0x6002335560006000fd") +) + +func newSnapshotRevertWorkload(cfg config, state *generatedState) *snapshotRevertWorkload { + state.SetCode(cfg.snapshotRevertContract, snapshotRevertOuterRuntimeCode) + state.SetCode(cfg.snapshotRevertHelper, snapshotRevertHelperRuntimeCode) + return &snapshotRevertWorkload{ + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.chainID), + } +} + +func (w *snapshotRevertWorkload) buildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.txsPerBlock) + for i := 0; i < w.cfg.txsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + raw, sender, err := w.buildCallTx(accountIndex) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.state.SetBalance(sender, w.cfg.senderBalance) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: blockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *snapshotRevertWorkload) buildCallTx(accountIndex uint64) ([]byte, common.Address, error) { + key, err := deterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: new(big.Int).Set(w.cfg.gasPrice), + Gas: w.cfg.txGasLimit, + To: &w.cfg.snapshotRevertContract, + Value: new(big.Int), + Data: addressCalldata(w.cfg.snapshotRevertHelper), + }) + signed, err := ethtypes.SignTx(tx, w.signer, key) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} + func erc20TransferCalldata(recipient common.Address, amount *big.Int) []byte { data := make([]byte, 4+32+32) copy(data[:4], erc20TransferSelector[:]) @@ -1031,12 +1135,22 @@ func erc20TransferCalldata(recipient common.Address, amount *big.Int) []byte { return data } +func addressCalldata(addr common.Address) []byte { + data := make([]byte, 32) + copy(data[12:], addr.Bytes()) + return data +} + func erc20BalanceSlot(owner common.Address) common.Hash { var encoded [64]byte copy(encoded[12:32], owner.Bytes()) return crypto.Keccak256Hash(encoded[:]) } +func snapshotRevertStorageSlot(sender common.Address) common.Hash { + return common.BytesToHash(sender.Bytes()) +} + func workloadRecipient(cfg config, conflictParticipants int, uniquePrefix string, conflictPrefix string, blockNumber uint64, txIndex int, accountIndex uint64) common.Address { if cfg.fixedRecipient != nil { return *cfg.fixedRecipient diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index c5ebd349a7..a45ede084e 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -104,6 +104,58 @@ func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { } } +func TestSnapshotRevertWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--workload=snapshot-revert", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + require.Equal(t, uint64(defaultSnapshotRevertTxGasLimit), cfg.txGasLimit) + + state := newGeneratedState() + workload, err := newWorkload(cfg, state) + require.NoError(t, err) + request, err := workload.buildBlock(context.Background(), 1) + require.NoError(t, err) + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: 4, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(context.Background(), request) + require.NoError(t, err) + + require.Len(t, result.Txs, cfg.txsPerBlock) + require.Len(t, result.Receipts, cfg.txsPerBlock) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + require.Zero(t, result.OCCStats.ConflictCount) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + require.Greater(t, tx.GasUsed, uint64(21_000)) + } + + require.Len(t, result.ChangeSet.Storage, cfg.txsPerBlock) + for _, change := range result.ChangeSet.Storage { + require.Equal(t, cfg.snapshotRevertContract, change.Address) + require.Equal(t, common.BigToHash(big.NewInt(1)), change.Value) + } + + applyGeneratedStateChangeSet(state, result.ChangeSet) + for i := uint64(1); i <= uint64(cfg.txsPerBlock); i++ { + key, err := deterministicPrivateKey(i) + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + slot := snapshotRevertStorageSlot(sender) + require.Equal(t, common.BigToHash(big.NewInt(1)), state.GetState(cfg.snapshotRevertContract, slot)) + require.Equal(t, common.Hash{}, state.GetState(cfg.snapshotRevertHelper, slot)) + } +} + func TestTransferWorkloadRecipientConflictRate(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", @@ -185,6 +237,27 @@ func TestRecipientConflictRateValidation(t *testing.T) { require.ErrorContains(t, err, "recipient cannot be combined with recipient-conflict-rate") } +func TestSnapshotRevertValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--workload=snapshot-revert", + "--recipient=0x0000000000000000000000000000000000000001", + }) + require.ErrorContains(t, err, "recipient is not supported with snapshot-revert workload") + + _, err = parseConfig([]string{ + "--workload=snapshot-revert", + "--recipient-conflict-rate=0.5", + }) + require.ErrorContains(t, err, "recipient-conflict-rate is not supported with snapshot-revert workload") + + _, err = parseConfig([]string{ + "--workload=snapshot-revert", + "--snapshot-revert-contract=0x0000000000000000000000000000000000000001", + "--snapshot-revert-helper=0x0000000000000000000000000000000000000001", + }) + require.ErrorContains(t, err, "snapshot-revert-contract and snapshot-revert-helper must differ") +} + func TestRunPrebuiltBlocks(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", From a8d2d95360c3cb431f123ae3b03b9b7dd3e4e5db Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 31 Jul 2026 13:33:59 +0800 Subject: [PATCH 22/34] add evmonly same-sender load scenario --- giga/evmonly/cmd/evmonly-loadtest/README.md | 37 +++- giga/evmonly/cmd/evmonly-loadtest/main.go | 25 ++- .../evmonly/cmd/evmonly-loadtest/main_test.go | 188 +++++++++++++++--- 3 files changed, 211 insertions(+), 39 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index c0c2a26515..2713581c55 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -5,13 +5,14 @@ the EVM-only executor without Cosmos SDK state, mempool, RPC, or persistence. It currently generates pure EVM legacy transfer transactions, ERC20 transfer transactions, and a contract-call workload that exercises nested StateDB -snapshot/revert behavior. Each generated sender account has exactly one nonce-0 -transaction and is funded in the command's in-memory genesis state before its -block is queued. Recipients are unique by default so the transfer workloads -exercise the optimistic no-overlap case. Pass +snapshot/revert behavior. By default, each generated sender account has one +nonce-0 transaction and is funded in the command's in-memory genesis state +before its block is queued. Recipients are unique by default so the transfer +workloads exercise the optimistic no-overlap case. Pass `--recipient-conflict-rate=<0..1>` to pair that fraction of each block's transactions onto shared recipients, or pass `--recipient=0x...` to force all -transactions to a single recipient. +transactions to a single recipient. Pass `--same-sender` to use one sender per +block with sequential transaction nonces. Run a bounded test: @@ -41,6 +42,24 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ --queue-size=512 ``` +The three executor OCC scenarios have direct command-line forms: + +```bash +# Conflict-free: unique senders and recipients. +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --gas-price-wei=0 --min-gas-price-wei=0 + +# Hot recipient: unique senders all credit one account. +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --gas-price-wei=0 --min-gas-price-wei=0 \ + --recipient=0x00000000000000000000000000000000000000f1 + +# Same-sender nonce chain: one sender and sequential nonces within each block. +go run ./giga/evmonly/cmd/evmonly-loadtest \ + --gas-price-wei=0 --min-gas-price-wei=0 \ + --same-sender +``` + Example conflict run: ```bash @@ -95,9 +114,9 @@ Prebuilding requires `--blocks > 0` and stores every raw block in memory. Sender recovery still runs in the measured phase, but it is pipelined ahead of execution through `--prepare-workers`. -The zero gas price/min-gas settings keep the transfer workload focused on the -optimistic no-overlap case. Non-zero fees make every transaction update the -same coinbase balance, which is a real intra-block conflict. +The zero gas price/min-gas settings keep the conflict-free transfer workload +focused on the optimistic no-overlap case. Non-zero fees make every transaction +update the same coinbase balance, which is a real intra-block conflict. Useful knobs: @@ -124,6 +143,8 @@ Useful knobs: - `--recipient-conflict-rate`: fraction of each block's transactions that are paired onto shared recipients; `0` keeps recipients unique and `1` pairs all possible transactions. +- `--same-sender`: use one sender per native-transfer block and assign + transaction nonces in block order. - `--workload`: workload type, either `transfer`, `erc20-transfer`, or `snapshot-revert`. - `--snapshot-revert-contract`, `--snapshot-revert-helper`: generated contract diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 4b4389e864..768549b7d5 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -99,6 +99,7 @@ type config struct { snapshotRevertHelper common.Address fixedRecipient *common.Address recipientConflictRate float64 + sameSender bool disableGasPriceRule bool prebuildBlocks bool } @@ -141,6 +142,7 @@ func parseConfig(args []string) (config, error) { snapshotRevertHelper := fs.String("snapshot-revert-helper", defaultSnapshotRevertHelper, "EVM address for the generated snapshot-revert helper contract") recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") fs.Float64Var(&cfg.recipientConflictRate, "recipient-conflict-rate", 0, "fraction [0,1] of transactions per block paired onto shared recipients; 0 keeps recipients unique") + fs.BoolVar(&cfg.sameSender, "same-sender", false, "use one sender with sequential nonces for every transaction in a transfer block") fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to feed; 0 runs until interrupted") fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") @@ -258,6 +260,9 @@ func parseConfig(args []string) (config, error) { if cfg.workload == workloadSnapshotRevert && cfg.recipientConflictRate != 0 { return config{}, fmt.Errorf("recipient-conflict-rate is not supported with snapshot-revert workload") } + if cfg.sameSender && cfg.workload != workloadTransfer { + return config{}, fmt.Errorf("same-sender is only supported with transfer workload") + } if cfg.workload == workloadSnapshotRevert && cfg.snapshotRevertContract == cfg.snapshotRevertHelper { return config{}, fmt.Errorf("snapshot-revert-contract and snapshot-revert-helper must differ") } @@ -303,6 +308,11 @@ func parseConfig(args []string) (config, error) { requiredBalance.Add(requiredBalance, cfg.transferValue) requiredBalanceReason = "transfer value plus max gas cost" } + if cfg.sameSender { + txCount := new(big.Int).SetUint64(uint64(cfg.txsPerBlock)) //nolint:gosec // txsPerBlock is validated as positive above. + requiredBalance.Mul(requiredBalance, txCount) + requiredBalanceReason = "all same-sender transactions' transfer value plus max gas cost" + } if cfg.senderBalance.Cmp(requiredBalance) < 0 { return config{}, fmt.Errorf("sender-balance-wei must cover %s: need at least %s", requiredBalanceReason, requiredBalance.String()) } @@ -933,8 +943,14 @@ func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmon default: } accountIndex := w.accountCursor.Add(1) + senderIndex := accountIndex + nonce := uint64(0) + if w.cfg.sameSender { + senderIndex = number + nonce = uint64(i) //nolint:gosec // i is bounded by txsPerBlock. + } recipient := w.recipient(number, i, accountIndex) - raw, sender, err := w.buildTransferTx(accountIndex, recipient) + raw, sender, err := w.buildTransferTx(senderIndex, nonce, recipient) if err != nil { return evmonly.BlockRequest{}, err } @@ -947,14 +963,14 @@ func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmon }, nil } -func (w *transferWorkload) buildTransferTx(accountIndex uint64, recipient common.Address) ([]byte, common.Address, error) { +func (w *transferWorkload) buildTransferTx(accountIndex, nonce uint64, recipient common.Address) ([]byte, common.Address, error) { key, err := deterministicPrivateKey(accountIndex) if err != nil { return nil, common.Address{}, err } sender := crypto.PubkeyToAddress(key.PublicKey) tx := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: 0, + Nonce: nonce, GasPrice: new(big.Int).Set(w.cfg.gasPrice), Gas: w.cfg.txGasLimit, To: &recipient, @@ -1248,7 +1264,6 @@ type generatedState struct { } var _ evmonly.StateReader = (*generatedState)(nil) -var _ evmonly.ConcurrentStateReader = (*generatedState)(nil) var frozenZeroBalance = new(big.Int) @@ -1265,8 +1280,6 @@ func (s *generatedState) Freeze() { s.frozen.Store(true) } -func (s *generatedState) ConcurrentReads() {} - func (s *generatedState) GetBalance(addr common.Address) *big.Int { if s.frozen.Load() { if balance, ok := s.balances[addr]; ok && balance != nil { diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index a45ede084e..4f3d8bb1da 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -53,6 +53,102 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { require.NoError(t, discardReceiptSink{}.StoreReceipts(context.Background(), request.Context.Number, result.Receipts)) } +func TestTransferWorkloadOCCScenarios(t *testing.T) { + tests := []struct { + name string + args []string + wantConflicts bool + wantReruns bool + wantSameSender bool + wantSameRecipient bool + }{ + {name: "conflict free"}, + { + name: "hot recipient", + args: []string{"--recipient=0x00000000000000000000000000000000000000f1"}, + wantConflicts: true, + wantReruns: true, + wantSameRecipient: true, + }, + { + name: "same sender nonce chain", + args: []string{"--same-sender"}, + wantReruns: true, + wantSameSender: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{ + "--metrics-addr=", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }, tc.args...) + cfg, err := parseConfig(args) + require.NoError(t, err) + + state := newGeneratedState() + workload := newTransferWorkload(cfg, state) + request, err := workload.buildBlock(context.Background(), 1) + require.NoError(t, err) + + signer := ethtypes.LatestSignerForChainID(cfg.chainID) + senders := make([]common.Address, len(request.Txs)) + recipients := make([]common.Address, len(request.Txs)) + for i, raw := range request.Txs { + tx := new(ethtypes.Transaction) + require.NoError(t, tx.UnmarshalBinary(raw)) + senders[i], err = ethtypes.Sender(signer, tx) + require.NoError(t, err) + require.NotNil(t, tx.To()) + recipients[i] = *tx.To() + if tc.wantSameSender { + require.Equal(t, uint64(i), tx.Nonce()) + } else { + require.Zero(t, tx.Nonce()) + } + } + for i := 1; i < len(request.Txs); i++ { + require.Equal(t, tc.wantSameSender, senders[i] == senders[0]) + require.Equal(t, tc.wantSameRecipient, recipients[i] == recipients[0]) + } + + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: 4, + }, evmonly.WithState(state)) + result, err := executor.ExecuteBlock(context.Background(), request) + require.NoError(t, err) + require.True(t, result.OCCStats.Attempted) + require.False(t, result.OCCStats.Fallback) + require.Len(t, result.Txs, cfg.txsPerBlock) + for _, tx := range result.Txs { + require.Equal(t, ethtypes.ReceiptStatusSuccessful, tx.Status) + require.NoError(t, tx.Err) + } + if tc.wantConflicts { + require.Greater(t, result.OCCStats.ConflictCount, uint64(0)) + } else { + require.Zero(t, result.OCCStats.ConflictCount) + } + if tc.wantReruns { + require.Greater(t, result.OCCStats.RerunCount, uint64(0)) + } else { + require.Zero(t, result.OCCStats.RerunCount) + } + + applyGeneratedStateChangeSet(state, result.ChangeSet) + if tc.wantSameSender { + require.Equal(t, uint64(cfg.txsPerBlock), state.GetNonce(senders[0])) + } + result.Release() + executor.Close() + }) + } +} + func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", @@ -237,6 +333,24 @@ func TestRecipientConflictRateValidation(t *testing.T) { require.ErrorContains(t, err, "recipient cannot be combined with recipient-conflict-rate") } +func TestSameSenderValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--workload=erc20-transfer", + "--same-sender", + }) + require.ErrorContains(t, err, "same-sender is only supported with transfer workload") + + _, err = parseConfig([]string{ + "--same-sender", + "--txs-per-block=4", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + "--sender-balance-wei=3", + "--transfer-value-wei=1", + }) + require.ErrorContains(t, err, "sender-balance-wei must cover all same-sender transactions") +} + func TestSnapshotRevertValidation(t *testing.T) { _, err := parseConfig([]string{ "--workload=snapshot-revert", @@ -435,30 +549,54 @@ func requireNoFileExists(t *testing.T, path string) { } func BenchmarkExecuteTransferBlock(b *testing.B) { - cfg, err := parseConfig([]string{ - "--metrics-addr=", - "--txs-per-block=1000", - }) - require.NoError(b, err) - - state := newGeneratedState() - workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(context.Background(), 1) - require.NoError(b, err) - executor := evmonly.NewExecutor(evmonly.Config{ - MinGasPrice: cfg.minGasPrice, - }, evmonly.WithState(state)) - - b.ReportAllocs() - b.SetBytes(int64(cfg.txsPerBlock)) - b.ResetTimer() - for i := 0; i < b.N; i++ { - result, err := executor.ExecuteBlock(context.Background(), request) - if err != nil { - b.Fatal(err) - } - if len(result.Txs) != cfg.txsPerBlock { - b.Fatalf("expected %d txs, got %d", cfg.txsPerBlock, len(result.Txs)) - } + tests := []struct { + name string + args []string + }{ + {name: "conflict_free"}, + { + name: "hot_recipient", + args: []string{"--recipient=0x00000000000000000000000000000000000000f1"}, + }, + { + name: "same_sender_nonce_chain", + args: []string{"--same-sender"}, + }, + } + for _, tc := range tests { + b.Run(tc.name, func(b *testing.B) { + args := append([]string{ + "--metrics-addr=", + "--txs-per-block=1000", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }, tc.args...) + cfg, err := parseConfig(args) + require.NoError(b, err) + + state := newGeneratedState() + workload := newTransferWorkload(cfg, state) + request, err := workload.buildBlock(context.Background(), 1) + require.NoError(b, err) + executor := evmonly.NewExecutor(evmonly.Config{ + MinGasPrice: cfg.minGasPrice, + OCCWorkers: cfg.executorWorkers, + }, evmonly.WithState(state)) + + b.ReportAllocs() + b.SetBytes(int64(cfg.txsPerBlock)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, err := executor.ExecuteBlock(context.Background(), request) + if err != nil { + b.Fatal(err) + } + if len(result.Txs) != cfg.txsPerBlock { + b.Fatalf("expected %d txs, got %d", cfg.txsPerBlock, len(result.Txs)) + } + result.Release() + } + executor.Close() + }) } } From e5b513a73942bfa439f5b05ffec23cbbf1740dd8 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 31 Jul 2026 14:47:03 +0800 Subject: [PATCH 23/34] report occ reruns in evmonly loadtest --- giga/evmonly/cmd/evmonly-loadtest/README.md | 1 + giga/evmonly/cmd/evmonly-loadtest/main.go | 21 +++++++++++++++---- .../evmonly/cmd/evmonly-loadtest/main_test.go | 13 ++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 2713581c55..ce1a7e5da5 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -158,6 +158,7 @@ The command reports these saturation signals on stdout and at `/metrics`: - block finishing throughput - finished transactions per second - total gas consumed per second +- total OCC transaction rerun attempts - prepared blocks queued for execution and cumulative totals The executor output is intentionally discarded through mocks: diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 768549b7d5..58519c83a9 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -1824,6 +1824,7 @@ type loadMetrics struct { executionErrors atomic.Uint64 occAttempts atomic.Uint64 occFallbacks atomic.Uint64 + occReruns atomic.Uint64 occConflicts atomic.Uint64 sinkEnqueued atomic.Uint64 sinkWritten atomic.Uint64 @@ -1843,6 +1844,7 @@ type loadMetrics struct { executionErrorsTotal prometheus.Counter occAttemptsTotal prometheus.Counter occFallbacksTotal prometheus.Counter + occRerunsTotal prometheus.Counter occConflictsTotal prometheus.Counter occFallbackReasonTotal *prometheus.CounterVec @@ -1877,6 +1879,7 @@ type metricsSnapshot struct { executionErrors uint64 occAttempts uint64 occFallbacks uint64 + occReruns uint64 occConflicts uint64 sinkEnqueued uint64 sinkWritten uint64 @@ -1929,6 +1932,10 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_occ_fallbacks_total", Help: "Total OCC blocks that fell back to sequential execution.", }), + occRerunsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_reruns_total", + Help: "Total OCC transaction rerun attempts caused by stale speculative execution state.", + }), occConflictsTotal: prometheus.NewCounter(prometheus.CounterOpts{ Name: "evmonly_loadtest_occ_conflicts_total", Help: "Total OCC conflict accesses observed before sequential fallback.", @@ -2013,6 +2020,7 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.executionErrorsTotal, m.occAttemptsTotal, m.occFallbacksTotal, + m.occRerunsTotal, m.occConflictsTotal, m.occFallbackReasonTotal, m.occConflictTotal, @@ -2077,6 +2085,10 @@ func (m *loadMetrics) recordOCC(stats evmonly.OCCStats) { m.occFallbacksTotal.Inc() m.occFallbackReasonTotal.WithLabelValues(reason).Inc() } + if stats.RerunCount > 0 { + m.occReruns.Add(stats.RerunCount) + m.occRerunsTotal.Add(float64(stats.RerunCount)) + } if stats.ConflictCount == 0 { return } @@ -2155,6 +2167,7 @@ func (m *loadMetrics) snapshot() metricsSnapshot { executionErrors: m.executionErrors.Load(), occAttempts: m.occAttempts.Load(), occFallbacks: m.occFallbacks.Load(), + occReruns: m.occReruns.Load(), occConflicts: m.occConflicts.Load(), sinkEnqueued: m.sinkEnqueued.Load(), sinkWritten: m.sinkWritten.Load(), @@ -2211,7 +2224,7 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) fmt.Printf( - "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_enqueued=%d sink_written=%d)\n", + "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", float64(curr.inputBlocks-prev.inputBlocks)/elapsed, float64(curr.preparedBlocks-prev.preparedBlocks)/elapsed, float64(curr.preparedTxs-prev.preparedTxs)/elapsed, @@ -2232,7 +2245,7 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric curr.executionErrors, curr.occAttempts, curr.occFallbacks, - curr.occConflicts, + curr.occReruns, curr.sinkEnqueued, curr.sinkWritten, ) @@ -2247,7 +2260,7 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { elapsed = 1 } fmt.Printf( - "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_conflicts=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", snapshot.at.Sub(startedAt).Round(time.Millisecond), snapshot.inputBlocks, snapshot.preparedBlocks, @@ -2259,7 +2272,7 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { snapshot.executionErrors, snapshot.occAttempts, snapshot.occFallbacks, - snapshot.occConflicts, + snapshot.occReruns, snapshot.sinkQueued, snapshot.sinkEnqueued, snapshot.sinkWritten, diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index 4f3d8bb1da..bbe11bd07b 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -384,6 +384,19 @@ func TestRunPrebuiltBlocks(t *testing.T) { require.NoError(t, run(cfg)) } +func TestLoadMetricsRecordsOCCRerunsWithoutConflicts(t *testing.T) { + metrics := newLoadMetrics(prometheus.NewRegistry()) + metrics.recordFinished(4, 84_000, evmonly.OCCStats{ + Attempted: true, + RerunCount: 3, + }) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(1), snapshot.occAttempts) + require.Equal(t, uint64(3), snapshot.occReruns) + require.Zero(t, snapshot.occConflicts) +} + func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ From f27ad402b1d0ecc536775bbe4567fea7b9836c35 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 3 Aug 2026 17:19:52 +0800 Subject: [PATCH 24/34] set default loadtest block gas limit --- giga/evmonly/cmd/evmonly-loadtest/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 58519c83a9..d49b397c4f 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -1205,10 +1205,14 @@ func recipientConflictParticipants(txsPerBlock int, rate float64) int { } func blockContext(cfg config, number uint64) evmonly.BlockContext { + gasLimit := cfg.blockGasLimit + if gasLimit == 0 { + gasLimit = math.MaxUint64 + } return evmonly.BlockContext{ Number: number, Time: uint64(time.Now().Unix()), - GasLimit: cfg.blockGasLimit, + GasLimit: gasLimit, ChainID: new(big.Int).Set(cfg.chainID), BaseFee: big.NewInt(0), BlobBaseFee: big.NewInt(0), From 181ab3c2df0bb92000899a95c73b21295d765bb2 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 4 Aug 2026 11:43:46 +0800 Subject: [PATCH 25/34] fix evm-only loadtest lint issues --- giga/evmonly/cmd/evmonly-loadtest/main.go | 76 +++++++++++++++++------ 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index d49b397c4f..82c7ad1a46 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -464,10 +464,10 @@ func startProfiles(cfg config) (*profileSession, error) { } func createProfileFile(path string) (*os.File, error) { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return nil, fmt.Errorf("create profile dir for %s: %w", path, err) } - file, err := os.Create(path) + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // profile output path is an explicit CLI argument. if err != nil { return nil, fmt.Errorf("create profile file %s: %w", path, err) } @@ -502,12 +502,14 @@ func (p *profileSession) Close() error { return errors.Join(errs...) } -func writeHeapProfile(path string) error { +func writeHeapProfile(path string) (err error) { file, err := createProfileFile(path) if err != nil { return err } - defer file.Close() + defer func() { + err = errors.Join(err, file.Close()) + }() runtime.GC() if err := pprof.WriteHeapProfile(file); err != nil { return fmt.Errorf("write heap profile %s: %w", path, err) @@ -722,10 +724,11 @@ func produceBlocks(ctx context.Context, cfg config, workload blockWorkload, out } func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorkload) ([]blockEnvelope, error) { - if cfg.blocks > uint64(maxInt()) { - return nil, fmt.Errorf("prebuild-blocks cannot allocate %d blocks on this platform", cfg.blocks) + blockCount, err := checkedIntFromUint64("prebuild-blocks", cfg.blocks) + if err != nil { + return nil, err } - prebuilt := make([]blockEnvelope, int(cfg.blocks)) + prebuilt := make([]blockEnvelope, blockCount) var nextBlock atomic.Uint64 group, groupCtx := errgroup.WithContext(ctx) for builderID := 0; builderID < cfg.builders; builderID++ { @@ -888,6 +891,39 @@ func maxInt() int { return int(^uint(0) >> 1) } +func checkedIntFromUint64(name string, value uint64) (int, error) { + if value > maxIntAsUint64() { + return 0, fmt.Errorf("%s cannot allocate %d blocks on this platform", name, value) + } + return int(value), nil //nolint:gosec // checked against max int above. +} + +func maxIntAsUint64() uint64 { + return uint64(^uint(0) >> 1) +} + +func uint64FromNonNegativeInt(value int) uint64 { + if value < 0 { + panic("negative integer cannot be converted to uint64") + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func uint64FromNonNegativeInt64(value int64) uint64 { + if value < 0 { + return 0 + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func durationFromUint64Nanos(nanos uint64) time.Duration { + const maxDurationNanos = uint64(1<<63 - 1) + if nanos > maxDurationNanos { + return time.Duration(maxDurationNanos) //nolint:gosec // capped to max int64 duration by construction. + } + return time.Duration(nanos) //nolint:gosec // capped to max int64 duration above. +} + func executeBlocks( ctx context.Context, workerID int, @@ -1172,7 +1208,7 @@ func workloadRecipient(cfg config, conflictParticipants int, uniquePrefix string return *cfg.fixedRecipient } if txIndex < conflictParticipants { - return blockScopedAddressFromSeed(conflictPrefix, blockNumber, uint64(txIndex/2)) + return blockScopedAddressFromSeed(conflictPrefix, blockNumber, uint64FromNonNegativeInt(txIndex/2)) } return addressFromSeed(uniquePrefix, accountIndex) } @@ -1211,7 +1247,7 @@ func blockContext(cfg config, number uint64) evmonly.BlockContext { } return evmonly.BlockContext{ Number: number, - Time: uint64(time.Now().Unix()), + Time: uint64FromNonNegativeInt64(time.Now().Unix()), GasLimit: gasLimit, ChainID: new(big.Int).Set(cfg.chainID), BaseFee: big.NewInt(0), @@ -1487,7 +1523,7 @@ type fileResultSinks struct { } func newFileResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { - if err := os.MkdirAll(cfg.persistDir, 0o755); err != nil { + if err := os.MkdirAll(cfg.persistDir, 0o750); err != nil { return nil, fmt.Errorf("create persist dir %s: %w", cfg.persistDir, err) } changeSetPath := filepath.Join(cfg.persistDir, "changesets.rlp") @@ -1755,7 +1791,7 @@ type appendRLPFile struct { } func newAppendRLPFile(path string, bufferSize int, syncOnWrite bool) (*appendRLPFile, error) { - file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // persist output path is an explicit CLI argument. if err != nil { return nil, fmt.Errorf("open persist file %s: %w", path, err) } @@ -2054,7 +2090,7 @@ func (m *loadMetrics) recordInput() { func (m *loadMetrics) recordPrepared(txCount int) { m.preparedBlocks.Add(1) - m.preparedTxs.Add(uint64(txCount)) + m.preparedTxs.Add(uint64FromNonNegativeInt(txCount)) m.preparedBlocksTotal.Inc() m.preparedTxsTotal.Add(float64(txCount)) } @@ -2066,7 +2102,7 @@ func (m *loadMetrics) recordPrepareError() { func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64, occStats evmonly.OCCStats) { m.finishedBlocks.Add(1) - m.finishedTxs.Add(uint64(txCount)) + m.finishedTxs.Add(uint64FromNonNegativeInt(txCount)) m.gasConsumed.Add(gasUsed) m.finishedBlocksTotal.Inc() m.finishedTxsTotal.Add(float64(txCount)) @@ -2129,7 +2165,7 @@ func (m *loadMetrics) recordSinkEnqueueWait(elapsed time.Duration) { if elapsed <= 0 { return } - m.sinkWaitNanos.Add(uint64(elapsed.Nanoseconds())) + m.sinkWaitNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) m.sinkWaitEvents.Add(1) m.sinkEnqueueWaitTotal.Add(elapsed.Seconds()) m.sinkEnqueueWaitEvents.Inc() @@ -2137,14 +2173,14 @@ func (m *loadMetrics) recordSinkEnqueueWait(elapsed time.Duration) { func (m *loadMetrics) recordSinkWrite(kind string, bytes int, elapsed time.Duration, completed bool) { if elapsed > 0 { - m.sinkWriteNanos.Add(uint64(elapsed.Nanoseconds())) + m.sinkWriteNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) m.sinkWriteSecondsTotal.WithLabelValues(kind).Add(elapsed.Seconds()) } if !completed { return } m.sinkWritten.Add(1) - m.sinkBytes.Add(uint64(bytes)) + m.sinkBytes.Add(uint64FromNonNegativeInt(bytes)) m.sinkWrittenTotal.WithLabelValues(kind).Inc() m.sinkBytesTotal.WithLabelValues(kind).Add(float64(bytes)) } @@ -2281,9 +2317,9 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { snapshot.sinkEnqueued, snapshot.sinkWritten, snapshot.sinkBytes, - time.Duration(snapshot.sinkWaitNanos).Round(time.Microsecond), + durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), snapshot.sinkWaitEvents, - time.Duration(snapshot.sinkWriteNanos).Round(time.Microsecond), + durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), float64(snapshot.inputBlocks)/elapsed, float64(snapshot.preparedBlocks)/elapsed, float64(snapshot.preparedTxs)/elapsed, @@ -2301,9 +2337,9 @@ func printResultSinkReport(closeElapsed time.Duration, snapshot metricsSnapshot) snapshot.sinkEnqueued, snapshot.sinkWritten, snapshot.sinkBytes, - time.Duration(snapshot.sinkWaitNanos).Round(time.Microsecond), + durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), snapshot.sinkWaitEvents, - time.Duration(snapshot.sinkWriteNanos).Round(time.Microsecond), + durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), ) } From e20d33233b481b4f2533c62a8c3584092d8749ce Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 4 Aug 2026 12:57:41 +0800 Subject: [PATCH 26/34] address evm-only loadtest review nits --- giga/evmonly/cmd/evmonly-loadtest/README.md | 25 +++- giga/evmonly/cmd/evmonly-loadtest/main.go | 84 ++++++------- .../evmonly/cmd/evmonly-loadtest/main_test.go | 114 ++++++++++++++++++ 3 files changed, 174 insertions(+), 49 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index ce1a7e5da5..e5b2410715 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -1,7 +1,8 @@ # evmonly-loadtest `evmonly-loadtest` is a standalone executable for feeding synthetic blocks to -the EVM-only executor without Cosmos SDK state, mempool, RPC, or persistence. +the EVM-only executor without Cosmos SDK state, mempool, RPC, or chain +persistence. It currently generates pure EVM legacy transfer transactions, ERC20 transfer transactions, and a contract-call workload that exercises nested StateDB @@ -131,6 +132,14 @@ Useful knobs: - `--result-pool-size`: reusable executor result slots. The default `0` sizes the pool for in-flight executor and async sink results; negative disables result pooling. +- `--result-sink`: executor output sink, either `discard` or `file`. The + default is `discard`. +- `--persist-dir`: directory used by `--result-sink=file` for temporary + append-only changeset and receipt files. Files are removed on shutdown or + interrupt cleanup. +- `--persist-buffer-size`: buffered writer size for `--result-sink=file`. +- `--persist-queue-size`: async file-sink record queue size. The default `0` + uses `2 * --queue-size`. - `--target-blocks-per-sec`: cap block input rate. The default `0` feeds as fast as block generation and the queue allow. - `--prebuild-blocks`: generate all bounded blocks before starting executor @@ -160,8 +169,10 @@ The command reports these saturation signals on stdout and at `/metrics`: - total gas consumed per second - total OCC transaction rerun attempts - prepared blocks queued for execution and cumulative totals +- result-sink records queued, enqueued, written, bytes written, enqueue wait, + and write time -The executor output is intentionally discarded through mocks: +The default executor output path intentionally discards results through mocks: - `generatedState` implements `evmonly.StateReader` and supplies generated genesis balances, nonces, code, and storage. @@ -169,6 +180,16 @@ The executor output is intentionally discarded through mocks: executor `StateChangeSet`. - `discardReceiptSink` sinks Ethereum receipts. +With `--result-sink=file`, the loadtest harness hands pooled +`evmonly.BlockResult` values to an async writer through the executor's +`evmonly.ResultSink` interface. The writer appends changesets to +`changesets.rlp` and receipts to `receipts.rlp` under `--persist-dir`; each +record is framed as an 8-byte big-endian block height, an 8-byte big-endian RLP +payload length, and the RLP payload. The files are temporary calibration +artifacts and are removed when the process exits normally or handles +`SIGINT`/`SIGTERM`. `sink_enqueue_wait` is the primary backpressure signal: a +non-zero value means executor workers waited for async sink queue capacity. + Future workloads should add another workload builder beside `transferWorkload`, `erc20TransferWorkload`, and `snapshotRevertWorkload`, then reuse the same block producer/prepare/executor/metrics pipeline. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 82c7ad1a46..1cd5ac6933 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -53,6 +53,7 @@ const ( defaultSnapshotRevertTxGasLimit = 100_000 defaultTxsPerBlock = 1_000 defaultPersistBuffer = 4 << 20 + defaultGenesisTimestamp = uint64(1_700_000_000) defaultWorkerCount = 1 defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" workloadTransfer = "transfer" @@ -156,7 +157,7 @@ func parseConfig(args []string) (config, error) { fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") fs.IntVar(&cfg.resultPoolSize, "result-pool-size", 0, "pooled executor BlockResult slots; 0 sizes for in-flight sink results, negative disables pooling") - fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files") + fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files, removed at shutdown") fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for async file persistence; 0 defaults to 2*queue-size") @@ -233,6 +234,9 @@ func parseConfig(args []string) (config, error) { if cfg.queueSize <= 0 { return config{}, fmt.Errorf("queue-size must be positive") } + if cfg.queueSize > math.MaxInt/2 { + return config{}, fmt.Errorf("queue-size must be at most %d", math.MaxInt/2) + } if cfg.builders <= 0 { return config{}, fmt.Errorf("builders must be positive") } @@ -779,7 +783,9 @@ func prepareBlocks( metrics *loadMetrics, ) error { unordered := make(chan preparedBlockEnvelope, cfg.queueSize) - group, groupCtx := errgroup.WithContext(ctx) + workerCtx, cancelWorkers := context.WithCancel(ctx) + defer cancelWorkers() + group, groupCtx := errgroup.WithContext(workerCtx) for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { workerID := workerID group.Go(func() error { @@ -793,10 +799,10 @@ func prepareBlocks( } prepared, err := executor.PrepareBlock(groupCtx, block.request) if err != nil { - metrics.recordPrepareError() if groupCtx.Err() != nil { return nil } + metrics.recordPrepareError() return fmt.Errorf("prepare worker %d prepare block %d: %w", workerID, block.number, err) } select { @@ -816,6 +822,9 @@ func prepareBlocks( done <- err }() orderErr := forwardPreparedBlocksInOrder(ctx, unordered, out) + if orderErr != nil { + cancelWorkers() + } workerErr := <-done if orderErr != nil { return orderErr @@ -887,10 +896,6 @@ func sendPreparedBlock(ctx context.Context, out chan<- preparedBlockEnvelope, bl } } -func maxInt() int { - return int(^uint(0) >> 1) -} - func checkedIntFromUint64(name string, value uint64) (int, error) { if value > maxIntAsUint64() { return 0, fmt.Errorf("%s cannot allocate %d blocks on this platform", name, value) @@ -941,10 +946,10 @@ func executeBlocks( } result, err := executor.ExecutePreparedBlock(ctx, block.block) if err != nil { - metrics.recordExecutionError() if ctx.Err() != nil { return nil } + metrics.recordExecutionError() return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) } metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) @@ -1247,7 +1252,7 @@ func blockContext(cfg config, number uint64) evmonly.BlockContext { } return evmonly.BlockContext{ Number: number, - Time: uint64FromNonNegativeInt64(time.Now().Unix()), + Time: blockTimestamp(number), GasLimit: gasLimit, ChainID: new(big.Int).Set(cfg.chainID), BaseFee: big.NewInt(0), @@ -1259,6 +1264,13 @@ func blockContext(cfg config, number uint64) evmonly.BlockContext { } } +func blockTimestamp(number uint64) uint64 { + if number > math.MaxUint64-defaultGenesisTimestamp { + return math.MaxUint64 + } + return defaultGenesisTimestamp + number +} + func deterministicPrivateKey(index uint64) (*ecdsa.PrivateKey, error) { var buf [16]byte binary.BigEndian.PutUint64(buf[:8], index) @@ -1706,15 +1718,16 @@ func (s *asyncFileResultSinks) recordEnqueued(kind string) { func (s *asyncFileResultSinks) run() { defer close(s.done) + var writeErr error for record := range s.records { if s.metrics != nil { s.metrics.setSinkQueued(len(s.records)) } - if err := s.writeRecord(record); err != nil { - record.releaseResult() - s.setErr(err) - s.releaseQueuedResults() - return + if writeErr == nil { + if err := s.writeRecord(record); err != nil { + writeErr = err + s.setErr(err) + } } record.releaseResult() if s.metrics != nil { @@ -1726,20 +1739,6 @@ func (s *asyncFileResultSinks) run() { } } -func (s *asyncFileResultSinks) releaseQueuedResults() { - for { - select { - case record, ok := <-s.records: - if !ok { - return - } - record.releaseResult() - default: - return - } - } -} - func (s *asyncFileResultSinks) writeRecord(record resultSinkRecord) error { if record.kind == resultSinkBlock { if err := s.files.WriteRecord(resultSinkChangeSet, record.height, record.result.ChangeSet); err != nil { @@ -1822,10 +1821,10 @@ func (f *appendRLPFile) WriteRecord(height uint64, value any) (int, error) { if _, err := f.writer.Write(payload); err != nil { return 0, fmt.Errorf("write record payload for height %d: %w", height, err) } - if err := f.writer.Flush(); err != nil { - return 0, fmt.Errorf("flush record for height %d: %w", height, err) - } if f.syncOnWrite { + if err := f.writer.Flush(); err != nil { + return 0, fmt.Errorf("flush record for height %d: %w", height, err) + } if err := f.file.Sync(); err != nil { return 0, fmt.Errorf("sync record for height %d: %w", height, err) } @@ -1888,7 +1887,7 @@ type loadMetrics struct { occConflictsTotal prometheus.Counter occFallbackReasonTotal *prometheus.CounterVec - occConflictTotal *prometheus.CounterVec + occConflictAccessTotal *prometheus.CounterVec sinkEnqueuedTotal *prometheus.CounterVec sinkWrittenTotal *prometheus.CounterVec sinkBytesTotal *prometheus.CounterVec @@ -1984,10 +1983,10 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_occ_fallback_reasons_total", Help: "OCC fallback count by reason.", }, []string{"reason"}), - occConflictTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "evmonly_loadtest_occ_conflict_keys_total", - Help: "OCC conflict accesses by access type, state kind, address, and slot.", - }, []string{"access", "kind", "address", "slot"}), + occConflictAccessTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_conflict_accesses_total", + Help: "OCC conflict accesses by access type and state kind.", + }, []string{"access", "kind"}), sinkEnqueuedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "evmonly_loadtest_result_sink_records_enqueued_total", Help: "Total records accepted by the result sink.", @@ -2063,7 +2062,7 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.occRerunsTotal, m.occConflictsTotal, m.occFallbackReasonTotal, - m.occConflictTotal, + m.occConflictAccessTotal, m.sinkEnqueuedTotal, m.sinkWrittenTotal, m.sinkBytesTotal, @@ -2135,22 +2134,13 @@ func (m *loadMetrics) recordOCC(stats evmonly.OCCStats) { m.occConflicts.Add(stats.ConflictCount) m.occConflictsTotal.Add(float64(stats.ConflictCount)) for _, conflict := range stats.ConflictSamples { - m.occConflictTotal.WithLabelValues( + m.occConflictAccessTotal.WithLabelValues( conflict.Access, conflict.Kind, - conflict.Address.Hex(), - conflictSlotLabel(conflict), ).Add(float64(conflict.Count)) } } -func conflictSlotLabel(conflict evmonly.OCCConflictCount) string { - if conflict.Kind != "storage" { - return "" - } - return conflict.Slot.Hex() -} - func (m *loadMetrics) recordExecutionError() { m.executionErrors.Add(1) m.executionErrorsTotal.Inc() diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index bbe11bd07b..a42137d0c8 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "errors" "fmt" + "math" "math/big" "os" "path/filepath" @@ -384,6 +385,54 @@ func TestRunPrebuiltBlocks(t *testing.T) { require.NoError(t, run(cfg)) } +func TestPrepareBlocksCancelsWorkersOnOrderingInvariantError(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--queue-size=1", + "--prepare-workers=1", + }) + require.NoError(t, err) + blocks := make(chan blockEnvelope, 2) + blocks <- blockEnvelope{number: 1} + blocks <- blockEnvelope{number: 1} + out := make(chan preparedBlockEnvelope, 1) + metrics := newLoadMetrics(prometheus.NewRegistry()) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- prepareBlocks(ctx, cfg, preparedOnlyExecutor{}, blocks, out, metrics) + }() + + select { + case err := <-errCh: + require.ErrorContains(t, err, "prepared block 1 arrived after block 1") + case <-ctx.Done(): + t.Fatal("prepareBlocks timed out after ordering invariant error") + } +} + +func TestPrepareBlocksDrainsPreparedBlocksAfterWorkersFinish(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--queue-size=2", + "--prepare-workers=1", + }) + require.NoError(t, err) + blocks := make(chan blockEnvelope, 2) + blocks <- blockEnvelope{number: 1} + blocks <- blockEnvelope{number: 2} + close(blocks) + out := make(chan preparedBlockEnvelope, 2) + metrics := newLoadMetrics(prometheus.NewRegistry()) + + require.NoError(t, prepareBlocks(context.Background(), cfg, preparedOnlyExecutor{}, blocks, out, metrics)) + require.Len(t, out, 2) +} + func TestLoadMetricsRecordsOCCRerunsWithoutConflicts(t *testing.T) { metrics := newLoadMetrics(prometheus.NewRegistry()) metrics.recordFinished(4, 84_000, evmonly.OCCStats{ @@ -397,12 +446,29 @@ func TestLoadMetricsRecordsOCCRerunsWithoutConflicts(t *testing.T) { require.Zero(t, snapshot.occConflicts) } +func TestBlockContextTimestampIsDeterministic(t *testing.T) { + cfg, err := parseConfig([]string{"--metrics-addr="}) + require.NoError(t, err) + + require.Equal(t, defaultGenesisTimestamp+10, blockContext(cfg, 10).Time) + require.Equal(t, blockContext(cfg, 10).Time, blockContext(cfg, 10).Time) + require.Less(t, blockContext(cfg, 10).Time, blockContext(cfg, 11).Time) +} + +func TestQueueSizeDefaultPersistQueueOverflowValidation(t *testing.T) { + _, err := parseConfig([]string{ + "--queue-size=" + fmt.Sprint(math.MaxInt), + }) + require.ErrorContains(t, err, "queue-size must be at most") +} + func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", "--result-sink=file", "--persist-dir=" + dir, + "--persist-sync", }) require.NoError(t, err) metrics := newLoadMetrics(prometheus.NewRegistry()) @@ -517,6 +583,40 @@ func TestAsyncFileResultSinkStoresBlockResultAndReleases(t *testing.T) { require.Equal(t, uint64(2), snapshot.sinkWritten) } +func TestAsyncFileResultSinkReleasesQueuedResultsAfterWriteError(t *testing.T) { + dir := t.TempDir() + recordFile, err := newAppendRLPFile(filepath.Join(dir, "records.rlp"), 1024, true) + require.NoError(t, err) + require.NoError(t, recordFile.file.Close()) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sink := &asyncFileResultSinks{ + files: &fileResultSinks{ + changeSetFile: recordFile, + receiptFile: recordFile, + metrics: metrics, + }, + metrics: metrics, + records: make(chan resultSinkRecord, 3), + done: make(chan struct{}), + } + + var releases atomic.Uint64 + for i := 0; i < cap(sink.records); i++ { + sink.records <- resultSinkRecord{ + kind: resultSinkBlock, + result: &evmonly.BlockResult{}, + release: func() { + releases.Add(1) + }, + } + } + close(sink.records) + sink.run() + + require.Error(t, sink.getErr()) + require.Equal(t, uint64(3), releases.Load()) +} + func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ @@ -536,6 +636,20 @@ func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { requireNoFileExists(t, filepath.Join(dir, "receipts.rlp")) } +type preparedOnlyExecutor struct{} + +func (preparedOnlyExecutor) ExecuteBlock(context.Context, evmonly.BlockRequest) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecuteBlock is unused") +} + +func (preparedOnlyExecutor) PrepareBlock(_ context.Context, request evmonly.BlockRequest) (evmonly.PreparedBlock, error) { + return evmonly.PreparedBlock{Context: request.Context}, nil +} + +func (preparedOnlyExecutor) ExecutePreparedBlock(context.Context, evmonly.PreparedBlock) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecutePreparedBlock is unused") +} + func readPersistedRLPRecord(t *testing.T, path string, out any) uint64 { t.Helper() data, err := os.ReadFile(path) From 902daa563b27c035dbf9c045639b897834ba4c54 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 4 Aug 2026 13:17:30 +0800 Subject: [PATCH 27/34] address additional evm-only loadtest review feedback --- giga/evmonly/cmd/evmonly-loadtest/README.md | 21 +- giga/evmonly/cmd/evmonly-loadtest/main.go | 219 +++++++++--------- .../evmonly/cmd/evmonly-loadtest/main_test.go | 160 ++++++++++--- 3 files changed, 263 insertions(+), 137 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index e5b2410715..780e391815 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -27,6 +27,11 @@ Run continuously until interrupted: go run ./giga/evmonly/cmd/evmonly-loadtest --txs-per-block=1000 ``` +Continuous mode with unique generated accounts is memory-limited: the in-memory +genesis state retains every funded sender account, and ERC20 runs also retain a +storage slot for every generated token holder. Use bounded `--blocks` runs when +comparing throughput, especially for long ERC20 or conflict-free transfer runs. + Example local saturation run: ```bash @@ -121,11 +126,19 @@ update the same coinbase balance, which is a real intra-block conflict. Useful knobs: -- `--workers`: parallel executor workers. The default is `1`. +- `--workers`: parallel executor workers. The default is `1`. Prepared blocks + are forwarded to workers in block-number order, but `--workers > 1` can still + finish execution out of order; this is safe for the harness because generated + state is frozen for prebuilt runs and executor changesets are not applied back + into the input state. - `--executor-workers`: parallel OCC workers inside each executor. The default is `min(12, GOMAXPROCS)`, following the `sei-v3` OCC worker default. - `--prepare-workers`: parallel stateless preparation workers used for transaction RLP decode and sender recovery. The default is `GOMAXPROCS`. +- `--parse-workers`: parallel transaction decode/sender recovery workers inside + each prepared block. The default `0` uses `1` when `--prepare-workers > 1` to + avoid multiplying block-level and intra-block parser pools, otherwise it uses + `GOMAXPROCS`. - `--builders`: parallel block builders used to keep the input queue full. The default is `GOMAXPROCS`. - `--queue-size`: buffered raw and prepared blocks. The default is `64`. @@ -171,14 +184,14 @@ The command reports these saturation signals on stdout and at `/metrics`: - prepared blocks queued for execution and cumulative totals - result-sink records queued, enqueued, written, bytes written, enqueue wait, and write time +- result-pool capacity, available slots, and overflow allocations The default executor output path intentionally discards results through mocks: - `generatedState` implements `evmonly.StateReader` and supplies generated genesis balances, nonces, code, and storage. -- `discardStateWriter` implements `evmonly.StateWriter` and sinks the - executor `StateChangeSet`. -- `discardReceiptSink` sinks Ethereum receipts. +- `discardResultSink` applies the executor `StateChangeSet` to + `discardStateWriter` and discards Ethereum receipts. With `--result-sink=file`, the loadtest harness hands pooled `evmonly.BlockResult` values to an async writer through the executor's diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 1cd5ac6933..73a7aaa7e3 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -61,7 +61,6 @@ const ( workloadSnapshotRevert = "snapshot-revert" resultSinkDiscard = "discard" resultSinkFile = "file" - resultSinkBlock = "block" resultSinkChangeSet = "changeset" resultSinkReceipts = "receipts" ) @@ -72,6 +71,7 @@ type config struct { queueSize int builders int prepareWorkers int + parseWorkers int workers int executorWorkers int targetBlocksPerSec float64 @@ -150,6 +150,7 @@ func parseConfig(args []string) (config, error) { fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") fs.IntVar(&cfg.prepareWorkers, "prepare-workers", defaultPrepareWorkers(), "parallel block preparation workers for transaction decode and sender recovery") + fs.IntVar(&cfg.parseWorkers, "parse-workers", 0, "parallel transaction decode/sender recovery workers inside each prepared block; 0 defaults to 1 when prepare-workers > 1, otherwise GOMAXPROCS") fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") @@ -243,6 +244,12 @@ func parseConfig(args []string) (config, error) { if cfg.prepareWorkers <= 0 { return config{}, fmt.Errorf("prepare-workers must be positive") } + if cfg.parseWorkers < 0 { + return config{}, fmt.Errorf("parse-workers must be non-negative") + } + if cfg.parseWorkers == 0 { + cfg.parseWorkers = defaultParseWorkers(cfg.prepareWorkers) + } if cfg.workers <= 0 { return config{}, fmt.Errorf("workers must be positive") } @@ -327,6 +334,13 @@ func defaultPrepareWorkers() int { return runtime.GOMAXPROCS(0) } +func defaultParseWorkers(prepareWorkers int) int { + if prepareWorkers > 1 { + return 1 + } + return runtime.GOMAXPROCS(0) +} + func defaultExecutorWorkers() int { workers := runtime.GOMAXPROCS(0) if workers > 12 { @@ -343,6 +357,16 @@ func defaultResultPoolSize(cfg config) int { return size } +func executorConfig(cfg config) evmonly.Config { + return evmonly.Config{ + MinGasPrice: new(big.Int).Set(cfg.minGasPrice), + DisableGasPriceCheck: cfg.disableGasPriceRule, + OCCWorkers: cfg.executorWorkers, + ParseWorkers: cfg.parseWorkers, + BlockResultPoolSize: cfg.resultPoolSize, + } +} + func parsePositiveBig(name, raw string) (*big.Int, error) { v, err := parseBig(name, raw) if err != nil { @@ -588,13 +612,9 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo startedAt := time.Now() group, groupCtx := errgroup.WithContext(ctx) - executor := evmonly.NewExecutor(evmonly.Config{ - MinGasPrice: new(big.Int).Set(cfg.minGasPrice), - DisableGasPriceCheck: cfg.disableGasPriceRule, - OCCWorkers: cfg.executorWorkers, - BlockResultPoolSize: cfg.resultPoolSize, - }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) + executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) defer executor.Close() + metrics.recordResultPoolStats(executor.ResultPoolStats()) group.Go(func() error { defer close(blocks) return produceBlocks(groupCtx, cfg, workload, blocks, metrics) @@ -611,6 +631,7 @@ func runStreaming(ctx context.Context, cfg config, state *generatedState, worklo } err = group.Wait() + metrics.recordResultPoolStats(executor.ResultPoolStats()) stopReporter() <-reportDone @@ -648,13 +669,9 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa startedAt := time.Now() group, groupCtx := errgroup.WithContext(ctx) - executor := evmonly.NewExecutor(evmonly.Config{ - MinGasPrice: new(big.Int).Set(cfg.minGasPrice), - DisableGasPriceCheck: cfg.disableGasPriceRule, - OCCWorkers: cfg.executorWorkers, - BlockResultPoolSize: cfg.resultPoolSize, - }, evmonly.WithState(state), evmonly.WithResultSink(sinks)) + executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) defer executor.Close() + metrics.recordResultPoolStats(executor.ResultPoolStats()) group.Go(func() error { defer close(blocks) return feedPrebuiltBlocks(groupCtx, prebuilt, blocks, metrics) @@ -671,6 +688,7 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa } err = group.Wait() + metrics.recordResultPoolStats(executor.ResultPoolStats()) stopReporter() <-reportDone @@ -678,6 +696,7 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa err = nil } printFinalReport(startedAt, metrics.snapshot()) + // Drop raw block retention before heap profiling forces a GC. prebuilt = nil finishProfiles(profiles, &err) return err @@ -826,10 +845,13 @@ func prepareBlocks( cancelWorkers() } workerErr := <-done + if workerErr != nil { + return errors.Join(workerErr, orderErr) + } if orderErr != nil { return orderErr } - return workerErr + return nil } func forwardPreparedBlocksInOrder(ctx context.Context, in <-chan preparedBlockEnvelope, out chan<- preparedBlockEnvelope) error { @@ -932,7 +954,7 @@ func durationFromUint64Nanos(nanos uint64) time.Duration { func executeBlocks( ctx context.Context, workerID int, - executor evmonly.PreparedBlockExecutor, + executor *evmonly.Executor, blocks <-chan preparedBlockEnvelope, metrics *loadMetrics, ) error { @@ -954,6 +976,7 @@ func executeBlocks( } metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) result.Release() + metrics.recordResultPoolStats(executor.ResultPoolStats()) } } } @@ -1334,6 +1357,8 @@ func (s *generatedState) Freeze() { func (s *generatedState) GetBalance(addr common.Address) *big.Int { if s.frozen.Load() { + // Frozen reads return shared, non-owned pointers; StateReader consumers + // must copy before mutation. if balance, ok := s.balances[addr]; ok && balance != nil { return balance } @@ -1440,19 +1465,10 @@ var _ evmonly.StateWriter = (*discardStateWriter)(nil) func (*discardStateWriter) ApplyChangeSet(evmonly.StateChangeSet) {} -type changeSetSink interface { - StoreChangeSet(ctx context.Context, height uint64, changeSet evmonly.StateChangeSet) error -} - -type receiptSink interface { - StoreReceipts(ctx context.Context, height uint64, receipts ethtypes.Receipts) error -} - type resultSinks struct { - changeSets changeSetSink - receipts receiptSink - close func() error - cleanup func() error + sink evmonly.ResultSink + close func() error + cleanup func() error } var _ evmonly.ResultSink = (*resultSinks)(nil) @@ -1461,8 +1477,7 @@ func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { switch cfg.resultSink { case resultSinkDiscard: return &resultSinks{ - changeSets: discardChangeSetSink{writer: &discardStateWriter{}}, - receipts: discardReceiptSink{}, + sink: discardResultSink{writer: &discardStateWriter{}}, }, nil case resultSinkFile: return newFileResultSinks(cfg, metrics) @@ -1471,26 +1486,8 @@ func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { } } -func (s *resultSinks) StoreChangeSet(ctx context.Context, height uint64, changeSet evmonly.StateChangeSet) error { - return s.changeSets.StoreChangeSet(ctx, height, changeSet) -} - -func (s *resultSinks) StoreReceipts(ctx context.Context, height uint64, receipts ethtypes.Receipts) error { - return s.receipts.StoreReceipts(ctx, height, receipts) -} - func (s *resultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { - if sink, ok := s.changeSets.(evmonly.ResultSink); ok { - return sink.StoreBlockResult(ctx, height, result, release) - } - if err := s.StoreChangeSet(ctx, height, result.ChangeSet); err != nil { - return err - } - if err := s.StoreReceipts(ctx, height, result.Receipts); err != nil { - return err - } - release() - return nil + return s.sink.StoreBlockResult(ctx, height, result, release) } func (s *resultSinks) Close() error { @@ -1510,18 +1507,13 @@ func (s *resultSinks) Cleanup() error { return s.cleanup() } -type discardChangeSetSink struct { +type discardResultSink struct { writer evmonly.StateWriter } -func (s discardChangeSetSink) StoreChangeSet(_ context.Context, _ uint64, changeSet evmonly.StateChangeSet) error { - s.writer.ApplyChangeSet(changeSet) - return nil -} - -type discardReceiptSink struct{} - -func (discardReceiptSink) StoreReceipts(context.Context, uint64, ethtypes.Receipts) error { +func (s discardResultSink) StoreBlockResult(_ context.Context, _ uint64, result *evmonly.BlockResult, release func()) error { + defer release() + s.writer.ApplyChangeSet(result.ChangeSet) return nil } @@ -1556,10 +1548,9 @@ func newFileResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) } async := newAsyncFileResultSinks(files, cfg.persistQueueSize, metrics) return &resultSinks{ - changeSets: async, - receipts: async, - close: async.Close, - cleanup: files.Cleanup, + sink: async, + close: async.Close, + cleanup: files.Cleanup, }, nil } @@ -1613,9 +1604,7 @@ func (s *fileResultSinks) WriteRecord(kind string, height uint64, value any) err } type resultSinkRecord struct { - kind string height uint64 - value any result *evmonly.BlockResult release func() } @@ -1645,25 +1634,8 @@ func newAsyncFileResultSinks(files *fileResultSinks, queueSize int, metrics *loa return s } -func (s *asyncFileResultSinks) StoreChangeSet(ctx context.Context, height uint64, changeSet evmonly.StateChangeSet) error { - return s.enqueue(ctx, resultSinkRecord{ - kind: resultSinkChangeSet, - height: height, - value: changeSet, - }) -} - -func (s *asyncFileResultSinks) StoreReceipts(ctx context.Context, height uint64, receipts ethtypes.Receipts) error { - return s.enqueue(ctx, resultSinkRecord{ - kind: resultSinkReceipts, - height: height, - value: receipts, - }) -} - func (s *asyncFileResultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { return s.enqueue(ctx, resultSinkRecord{ - kind: resultSinkBlock, height: height, result: result, release: release, @@ -1676,7 +1648,7 @@ func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRec } select { case s.records <- record: - s.recordEnqueued(record.kind) + s.recordEnqueued() return nil default: } @@ -1687,7 +1659,7 @@ func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRec if s.metrics != nil { s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) } - s.recordEnqueued(record.kind) + s.recordEnqueued() return nil case <-s.done: if err := s.getErr(); err != nil { @@ -1702,17 +1674,12 @@ func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRec } } -func (s *asyncFileResultSinks) recordEnqueued(kind string) { +func (s *asyncFileResultSinks) recordEnqueued() { if s.metrics == nil { return } - if kind == resultSinkBlock { - s.metrics.recordSinkEnqueued(resultSinkChangeSet) - s.metrics.recordSinkEnqueued(resultSinkReceipts) - s.metrics.setSinkQueued(len(s.records)) - return - } - s.metrics.recordSinkEnqueued(kind) + s.metrics.recordSinkEnqueued(resultSinkChangeSet) + s.metrics.recordSinkEnqueued(resultSinkReceipts) s.metrics.setSinkQueued(len(s.records)) } @@ -1740,13 +1707,10 @@ func (s *asyncFileResultSinks) run() { } func (s *asyncFileResultSinks) writeRecord(record resultSinkRecord) error { - if record.kind == resultSinkBlock { - if err := s.files.WriteRecord(resultSinkChangeSet, record.height, record.result.ChangeSet); err != nil { - return err - } - return s.files.WriteRecord(resultSinkReceipts, record.height, record.result.Receipts) + if err := s.files.WriteRecord(resultSinkChangeSet, record.height, record.result.ChangeSet); err != nil { + return err } - return s.files.WriteRecord(record.kind, record.height, record.value) + return s.files.WriteRecord(resultSinkReceipts, record.height, record.result.Receipts) } func (r resultSinkRecord) releaseResult() { @@ -1872,6 +1836,9 @@ type loadMetrics struct { sinkWaitEvents atomic.Uint64 sinkWriteNanos atomic.Uint64 sinkQueued atomic.Int64 + poolCapacity atomic.Int64 + poolAvailable atomic.Int64 + poolOverflow atomic.Uint64 inputBlocksTotal prometheus.Counter preparedBlocksTotal prometheus.Counter @@ -1895,15 +1862,18 @@ type loadMetrics struct { sinkEnqueueWaitEvents prometheus.Counter sinkWriteSecondsTotal *prometheus.CounterVec - inputBlockRate prometheus.Gauge - preparedBlockRate prometheus.Gauge - preparedTxRate prometheus.Gauge - finishedBlockRate prometheus.Gauge - txRate prometheus.Gauge - gasRate prometheus.Gauge - queuedBlocks prometheus.Gauge - sinkQueuedRecords prometheus.Gauge - sinkQueueCapacity prometheus.Gauge + inputBlockRate prometheus.Gauge + preparedBlockRate prometheus.Gauge + preparedTxRate prometheus.Gauge + finishedBlockRate prometheus.Gauge + txRate prometheus.Gauge + gasRate prometheus.Gauge + queuedBlocks prometheus.Gauge + sinkQueuedRecords prometheus.Gauge + sinkQueueCapacity prometheus.Gauge + poolCapacityGauge prometheus.Gauge + poolAvailableGauge prometheus.Gauge + poolOverflowGauge prometheus.Gauge } type metricsSnapshot struct { @@ -1927,6 +1897,9 @@ type metricsSnapshot struct { sinkWaitEvents uint64 sinkWriteNanos uint64 sinkQueued int64 + poolCapacity int64 + poolAvailable int64 + poolOverflow uint64 } func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { @@ -2047,6 +2020,18 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_result_sink_queue_capacity", Help: "Capacity of the persistent result sink async record queue.", }), + poolCapacityGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_capacity", + Help: "Capacity of the reusable executor block result pool.", + }), + poolAvailableGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_available", + Help: "Reusable executor block result slots currently available.", + }), + poolOverflowGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_overflow_allocations", + Help: "Total block results allocated outside the reusable pool because all slots were retained.", + }), } registry.MustRegister( m.inputBlocksTotal, @@ -2078,6 +2063,9 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.queuedBlocks, m.sinkQueuedRecords, m.sinkQueueCapacity, + m.poolCapacityGauge, + m.poolAvailableGauge, + m.poolOverflowGauge, ) return m } @@ -2175,6 +2163,15 @@ func (m *loadMetrics) recordSinkWrite(kind string, bytes int, elapsed time.Durat m.sinkBytesTotal.WithLabelValues(kind).Add(float64(bytes)) } +func (m *loadMetrics) recordResultPoolStats(stats evmonly.BlockResultPoolStats) { + m.poolCapacity.Store(int64(stats.Capacity)) + m.poolAvailable.Store(int64(stats.Available)) + m.poolOverflow.Store(stats.OverflowAllocations) + m.poolCapacityGauge.Set(float64(stats.Capacity)) + m.poolAvailableGauge.Set(float64(stats.Available)) + m.poolOverflowGauge.Set(float64(stats.OverflowAllocations)) +} + func (m *loadMetrics) setSinkQueued(records int) { m.sinkQueued.Store(int64(records)) m.sinkQueuedRecords.Set(float64(records)) @@ -2206,6 +2203,9 @@ func (m *loadMetrics) snapshot() metricsSnapshot { sinkWaitEvents: m.sinkWaitEvents.Load(), sinkWriteNanos: m.sinkWriteNanos.Load(), sinkQueued: m.sinkQueued.Load(), + poolCapacity: m.poolCapacity.Load(), + poolAvailable: m.poolAvailable.Load(), + poolOverflow: m.poolOverflow.Load(), } } @@ -2254,7 +2254,7 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) fmt.Printf( - "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", + "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d pool_available=%d pool_overflow=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", float64(curr.inputBlocks-prev.inputBlocks)/elapsed, float64(curr.preparedBlocks-prev.preparedBlocks)/elapsed, float64(curr.preparedTxs-prev.preparedTxs)/elapsed, @@ -2263,6 +2263,8 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric float64(curr.gasConsumed-prev.gasConsumed)/elapsed, queued, curr.sinkQueued, + curr.poolAvailable, + curr.poolOverflow, sinkWaitSeconds/elapsed, sinkWriteSeconds/elapsed, curr.inputBlocks, @@ -2290,7 +2292,7 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { elapsed = 1 } fmt.Printf( - "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s pool_capacity=%d pool_available=%d pool_overflow=%d avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", snapshot.at.Sub(startedAt).Round(time.Millisecond), snapshot.inputBlocks, snapshot.preparedBlocks, @@ -2310,6 +2312,9 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), snapshot.sinkWaitEvents, durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), + snapshot.poolCapacity, + snapshot.poolAvailable, + snapshot.poolOverflow, float64(snapshot.inputBlocks)/elapsed, float64(snapshot.preparedBlocks)/elapsed, float64(snapshot.preparedTxs)/elapsed, diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index a42137d0c8..c9888f5995 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -9,6 +9,7 @@ import ( "math/big" "os" "path/filepath" + "runtime" "sync/atomic" "testing" "time" @@ -32,13 +33,13 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { state := newGeneratedState() workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(context.Background(), 1) + request, err := workload.buildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ MinGasPrice: cfg.minGasPrice, }, evmonly.WithState(state)) - result, err := executor.ExecuteBlock(context.Background(), request) + result, err := executor.ExecuteBlock(t.Context(), request) require.NoError(t, err) require.Len(t, result.Txs, cfg.txsPerBlock) @@ -49,9 +50,11 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { require.NoError(t, tx.Err) } - writer := &discardStateWriter{} - writer.ApplyChangeSet(result.ChangeSet) - require.NoError(t, discardReceiptSink{}.StoreReceipts(context.Background(), request.Context.Number, result.Receipts)) + var released atomic.Bool + require.NoError(t, discardResultSink{writer: &discardStateWriter{}}.StoreBlockResult(t.Context(), request.Context.Number, result, func() { + released.Store(true) + })) + require.True(t, released.Load()) } func TestTransferWorkloadOCCScenarios(t *testing.T) { @@ -92,7 +95,7 @@ func TestTransferWorkloadOCCScenarios(t *testing.T) { state := newGeneratedState() workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(context.Background(), 1) + request, err := workload.buildBlock(t.Context(), 1) require.NoError(t, err) signer := ethtypes.LatestSignerForChainID(cfg.chainID) @@ -120,7 +123,7 @@ func TestTransferWorkloadOCCScenarios(t *testing.T) { MinGasPrice: cfg.minGasPrice, OCCWorkers: 4, }, evmonly.WithState(state)) - result, err := executor.ExecuteBlock(context.Background(), request) + result, err := executor.ExecuteBlock(t.Context(), request) require.NoError(t, err) require.True(t, result.OCCStats.Attempted) require.False(t, result.OCCStats.Fallback) @@ -164,14 +167,14 @@ func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { state := newGeneratedState() workload, err := newWorkload(cfg, state) require.NoError(t, err) - request, err := workload.buildBlock(context.Background(), 1) + request, err := workload.buildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ MinGasPrice: cfg.minGasPrice, OCCWorkers: cfg.executorWorkers, }, evmonly.WithState(state)) - result, err := executor.ExecuteBlock(context.Background(), request) + result, err := executor.ExecuteBlock(t.Context(), request) require.NoError(t, err) require.Len(t, result.Txs, cfg.txsPerBlock) @@ -215,14 +218,14 @@ func TestSnapshotRevertWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { state := newGeneratedState() workload, err := newWorkload(cfg, state) require.NoError(t, err) - request, err := workload.buildBlock(context.Background(), 1) + request, err := workload.buildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ MinGasPrice: cfg.minGasPrice, OCCWorkers: 4, }, evmonly.WithState(state)) - result, err := executor.ExecuteBlock(context.Background(), request) + result, err := executor.ExecuteBlock(t.Context(), request) require.NoError(t, err) require.Len(t, result.Txs, cfg.txsPerBlock) @@ -265,14 +268,14 @@ func TestTransferWorkloadRecipientConflictRate(t *testing.T) { state := newGeneratedState() workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(context.Background(), 1) + request, err := workload.buildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ MinGasPrice: cfg.minGasPrice, OCCWorkers: 4, }, evmonly.WithState(state)) - result, err := executor.ExecuteBlock(context.Background(), request) + result, err := executor.ExecuteBlock(t.Context(), request) require.NoError(t, err) require.True(t, result.OCCStats.Attempted) require.False(t, result.OCCStats.Fallback) @@ -373,6 +376,32 @@ func TestSnapshotRevertValidation(t *testing.T) { require.ErrorContains(t, err, "snapshot-revert-contract and snapshot-revert-helper must differ") } +func TestParseWorkersConfig(t *testing.T) { + cfg, err := parseConfig([]string{ + "--prepare-workers=2", + }) + require.NoError(t, err) + require.Equal(t, 1, cfg.parseWorkers) + + cfg, err = parseConfig([]string{ + "--prepare-workers=1", + }) + require.NoError(t, err) + require.Equal(t, runtime.GOMAXPROCS(0), cfg.parseWorkers) + + cfg, err = parseConfig([]string{ + "--prepare-workers=8", + "--parse-workers=3", + }) + require.NoError(t, err) + require.Equal(t, 3, cfg.parseWorkers) + + _, err = parseConfig([]string{ + "--parse-workers=-1", + }) + require.ErrorContains(t, err, "parse-workers must be non-negative") +} + func TestRunPrebuiltBlocks(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", @@ -399,7 +428,7 @@ func TestPrepareBlocksCancelsWorkersOnOrderingInvariantError(t *testing.T) { out := make(chan preparedBlockEnvelope, 1) metrics := newLoadMetrics(prometheus.NewRegistry()) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() errCh := make(chan error, 1) go func() { @@ -414,6 +443,26 @@ func TestPrepareBlocksCancelsWorkersOnOrderingInvariantError(t *testing.T) { } } +func TestPrepareBlocksPrefersWorkerErrorOverOrderingDrainError(t *testing.T) { + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--report-interval=0", + "--queue-size=2", + "--prepare-workers=1", + }) + require.NoError(t, err) + blocks := make(chan blockEnvelope, 2) + blocks <- blockEnvelope{number: 2, request: evmonly.BlockRequest{Context: evmonly.BlockContext{Number: 2}}} + blocks <- blockEnvelope{number: 1, request: evmonly.BlockRequest{Context: evmonly.BlockContext{Number: 1}}} + close(blocks) + out := make(chan preparedBlockEnvelope, 2) + metrics := newLoadMetrics(prometheus.NewRegistry()) + + err = prepareBlocks(t.Context(), cfg, failingPreparedExecutor{}, blocks, out, metrics) + require.ErrorContains(t, err, "prepare worker 0 prepare block 1: injected prepare failure") + require.ErrorContains(t, err, "prepared block stream closed before block 1") +} + func TestPrepareBlocksDrainsPreparedBlocksAfterWorkersFinish(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", @@ -429,7 +478,7 @@ func TestPrepareBlocksDrainsPreparedBlocksAfterWorkersFinish(t *testing.T) { out := make(chan preparedBlockEnvelope, 2) metrics := newLoadMetrics(prometheus.NewRegistry()) - require.NoError(t, prepareBlocks(context.Background(), cfg, preparedOnlyExecutor{}, blocks, out, metrics)) + require.NoError(t, prepareBlocks(t.Context(), cfg, preparedOnlyExecutor{}, blocks, out, metrics)) require.Len(t, out, 2) } @@ -488,10 +537,15 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { Status: ethtypes.ReceiptStatusSuccessful, TxHash: common.HexToHash("0x01"), }} - require.NoError(t, sinks.StoreChangeSet(context.Background(), 1, writtenChangeSet)) - require.NoError(t, sinks.StoreReceipts(context.Background(), 1, writtenReceipts)) + var released atomic.Bool + require.NoError(t, sinks.StoreBlockResult(t.Context(), 1, &evmonly.BlockResult{ + ChangeSet: writtenChangeSet, + Receipts: writtenReceipts, + }, func() { + released.Store(true) + })) require.Eventually(t, func() bool { - return metrics.snapshot().sinkWritten == 2 + return metrics.snapshot().sinkWritten == 2 && released.Load() }, time.Second, time.Millisecond) requireFileExists(t, changePath) @@ -506,7 +560,7 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { require.Equal(t, uint64(1), height) require.Len(t, receipts, 1) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(t.Context()) stopCleanup := cleanupSinksOnContextCancel(ctx, sinks) cancel() stopCleanup() @@ -530,10 +584,9 @@ func TestAsyncFileResultSinkReportsMetrics(t *testing.T) { sinks, err := newResultSinks(cfg, metrics) require.NoError(t, err) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(t.Context()) defer cancel() - require.NoError(t, sinks.StoreChangeSet(ctx, 1, evmonly.StateChangeSet{})) - require.NoError(t, sinks.StoreReceipts(ctx, 1, ethtypes.Receipts{})) + require.NoError(t, sinks.StoreBlockResult(ctx, 1, &evmonly.BlockResult{}, func() {})) require.NoError(t, sinks.Close()) snapshot := metrics.snapshot() @@ -570,7 +623,7 @@ func TestAsyncFileResultSinkStoresBlockResultAndReleases(t *testing.T) { }}, } var released atomic.Bool - require.NoError(t, sinks.StoreBlockResult(context.Background(), 1, result, func() { + require.NoError(t, sinks.StoreBlockResult(t.Context(), 1, result, func() { released.Store(true) })) require.Eventually(t, func() bool { @@ -603,7 +656,6 @@ func TestAsyncFileResultSinkReleasesQueuedResultsAfterWriteError(t *testing.T) { var releases atomic.Uint64 for i := 0; i < cap(sink.records); i++ { sink.records <- resultSinkRecord{ - kind: resultSinkBlock, result: &evmonly.BlockResult{}, release: func() { releases.Add(1) @@ -617,6 +669,45 @@ func TestAsyncFileResultSinkReleasesQueuedResultsAfterWriteError(t *testing.T) { require.Equal(t, uint64(3), releases.Load()) } +func TestExecutorResultPoolReusesSlotsWithFileSink(t *testing.T) { + dir := t.TempDir() + cfg, err := parseConfig([]string{ + "--metrics-addr=", + "--result-sink=file", + "--persist-dir=" + dir, + "--result-pool-size=1", + "--txs-per-block=2", + "--gas-price-wei=0", + "--min-gas-price-wei=0", + }) + require.NoError(t, err) + state := newGeneratedState() + workload := newTransferWorkload(cfg, state) + metrics := newLoadMetrics(prometheus.NewRegistry()) + sinks, err := newResultSinks(cfg, metrics) + require.NoError(t, err) + defer func() { + require.NoError(t, sinks.Close()) + }() + executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) + defer executor.Close() + + for blockNumber := uint64(1); blockNumber <= 20; blockNumber++ { + request, err := workload.buildBlock(t.Context(), blockNumber) + require.NoError(t, err) + result, err := executor.ExecuteBlock(t.Context(), request) + require.NoError(t, err) + result.Release() + require.Eventually(t, func() bool { + return executor.ResultPoolStats().Available == 1 + }, time.Second, time.Millisecond) + } + require.Equal(t, evmonly.BlockResultPoolStats{ + Capacity: 1, + Available: 1, + }, executor.ResultPoolStats()) +} + func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ @@ -650,6 +741,23 @@ func (preparedOnlyExecutor) ExecutePreparedBlock(context.Context, evmonly.Prepar return nil, errors.New("ExecutePreparedBlock is unused") } +type failingPreparedExecutor struct{} + +func (failingPreparedExecutor) ExecuteBlock(context.Context, evmonly.BlockRequest) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecuteBlock is unused") +} + +func (failingPreparedExecutor) PrepareBlock(_ context.Context, request evmonly.BlockRequest) (evmonly.PreparedBlock, error) { + if request.Context.Number == 1 { + return evmonly.PreparedBlock{}, errors.New("injected prepare failure") + } + return evmonly.PreparedBlock{Context: request.Context}, nil +} + +func (failingPreparedExecutor) ExecutePreparedBlock(context.Context, evmonly.PreparedBlock) (*evmonly.BlockResult, error) { + return nil, errors.New("ExecutePreparedBlock is unused") +} + func readPersistedRLPRecord(t *testing.T, path string, out any) uint64 { t.Helper() data, err := os.ReadFile(path) @@ -703,7 +811,7 @@ func BenchmarkExecuteTransferBlock(b *testing.B) { state := newGeneratedState() workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(context.Background(), 1) + request, err := workload.buildBlock(b.Context(), 1) require.NoError(b, err) executor := evmonly.NewExecutor(evmonly.Config{ MinGasPrice: cfg.minGasPrice, @@ -714,7 +822,7 @@ func BenchmarkExecuteTransferBlock(b *testing.B) { b.SetBytes(int64(cfg.txsPerBlock)) b.ResetTimer() for i := 0; i < b.N; i++ { - result, err := executor.ExecuteBlock(context.Background(), request) + result, err := executor.ExecuteBlock(b.Context(), request) if err != nil { b.Fatal(err) } From c0ebb10438f91568b23cd1235379a90bdfe3e8aa Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 4 Aug 2026 15:46:29 +0800 Subject: [PATCH 28/34] remove evm-only loadtest streaming mode --- giga/evmonly/cmd/evmonly-loadtest/README.md | 31 ++--- giga/evmonly/cmd/evmonly-loadtest/main.go | 119 ++---------------- .../evmonly/cmd/evmonly-loadtest/main_test.go | 37 ++++-- 3 files changed, 47 insertions(+), 140 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 780e391815..14d5c89910 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -15,23 +15,12 @@ transactions onto shared recipients, or pass `--recipient=0x...` to force all transactions to a single recipient. Pass `--same-sender` to use one sender per block with sequential transaction nonces. -Run a bounded test: +Run a bounded prebuilt test: ```bash go run ./giga/evmonly/cmd/evmonly-loadtest --blocks=1000 --txs-per-block=1000 ``` -Run continuously until interrupted: - -```bash -go run ./giga/evmonly/cmd/evmonly-loadtest --txs-per-block=1000 -``` - -Continuous mode with unique generated accounts is memory-limited: the in-memory -genesis state retains every funded sender account, and ERC20 runs also retain a -storage slot for every generated token holder. Use bounded `--blocks` runs when -comparing throughput, especially for long ERC20 or conflict-free transfer runs. - Example local saturation run: ```bash @@ -53,15 +42,18 @@ The three executor OCC scenarios have direct command-line forms: ```bash # Conflict-free: unique senders and recipients. go run ./giga/evmonly/cmd/evmonly-loadtest \ + --blocks=400 \ --gas-price-wei=0 --min-gas-price-wei=0 # Hot recipient: unique senders all credit one account. go run ./giga/evmonly/cmd/evmonly-loadtest \ + --blocks=400 \ --gas-price-wei=0 --min-gas-price-wei=0 \ --recipient=0x00000000000000000000000000000000000000f1 # Same-sender nonce chain: one sender and sequential nonces within each block. go run ./giga/evmonly/cmd/evmonly-loadtest \ + --blocks=400 \ --gas-price-wei=0 --min-gas-price-wei=0 \ --same-sender ``` @@ -98,14 +90,13 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ --min-gas-price-wei=0 ``` -To isolate executor throughput from block generation, prebuild a bounded run -before starting the prepare/recover and executor workers: +The command prebuilds every bounded run before starting the prepare/recover and +executor workers, which isolates executor throughput from block generation: ```bash go run ./giga/evmonly/cmd/evmonly-loadtest \ --metrics-addr= \ --report-interval=5s \ - --prebuild-blocks \ --blocks=400 \ --txs-per-block=5000 \ --builders=48 \ @@ -116,8 +107,8 @@ go run ./giga/evmonly/cmd/evmonly-loadtest \ --queue-size=512 ``` -Prebuilding requires `--blocks > 0` and stores every raw block in memory. -Sender recovery still runs in the measured phase, but it is pipelined ahead of +Prebuilding requires `--blocks > 0` and stores every raw block in memory. Sender +recovery still runs in the measured phase, but it is pipelined ahead of execution through `--prepare-workers`. The zero gas price/min-gas settings keep the conflict-free transfer workload @@ -126,6 +117,8 @@ update the same coinbase balance, which is a real intra-block conflict. Useful knobs: +- `--blocks`: number of blocks to prebuild and execute. This is required and + must be greater than `0`. - `--workers`: parallel executor workers. The default is `1`. Prepared blocks are forwarded to workers in block-number order, but `--workers > 1` can still finish execution out of order; this is safe for the harness because generated @@ -153,10 +146,6 @@ Useful knobs: - `--persist-buffer-size`: buffered writer size for `--result-sink=file`. - `--persist-queue-size`: async file-sink record queue size. The default `0` uses `2 * --queue-size`. -- `--target-blocks-per-sec`: cap block input rate. The default `0` feeds as - fast as block generation and the queue allow. -- `--prebuild-blocks`: generate all bounded blocks before starting executor - workers. This separates build throughput from executor throughput. - `--metrics-addr`: Prometheus endpoint. The default is `127.0.0.1:9698`; set it to empty to disable HTTP metrics. - `--report-interval`: stdout rate reporting interval. The default is `5s`. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 73a7aaa7e3..103798b647 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -31,7 +31,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" - "golang.org/x/time/rate" "github.com/sei-protocol/sei-chain/giga/evmonly" ) @@ -74,7 +73,6 @@ type config struct { parseWorkers int workers int executorWorkers int - targetBlocksPerSec float64 reportInterval time.Duration metricsAddr string resultSink string @@ -102,7 +100,6 @@ type config struct { recipientConflictRate float64 sameSender bool disableGasPriceRule bool - prebuildBlocks bool } type blockEnvelope struct { @@ -145,7 +142,7 @@ func parseConfig(args []string) (config, error) { fs.Float64Var(&cfg.recipientConflictRate, "recipient-conflict-rate", 0, "fraction [0,1] of transactions per block paired onto shared recipients; 0 keeps recipients unique") fs.BoolVar(&cfg.sameSender, "same-sender", false, "use one sender with sequential nonces for every transaction in a transfer block") - fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to feed; 0 runs until interrupted") + fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to prebuild and execute; must be positive") fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") @@ -153,7 +150,6 @@ func parseConfig(args []string) (config, error) { fs.IntVar(&cfg.parseWorkers, "parse-workers", 0, "parallel transaction decode/sender recovery workers inside each prepared block; 0 defaults to 1 when prepare-workers > 1, otherwise GOMAXPROCS") fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") - fs.Float64Var(&cfg.targetBlocksPerSec, "target-blocks-per-sec", 0, "input block rate cap; 0 means unlimited") fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") @@ -162,14 +158,13 @@ func parseConfig(args []string) (config, error) { fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for async file persistence; 0 defaults to 2*queue-size") - fs.StringVar(&cfg.cpuProfile, "cpu-profile", "", "write Go CPU profile to this file; starts after prebuild when --prebuild-blocks is set") + fs.StringVar(&cfg.cpuProfile, "cpu-profile", "", "write Go CPU profile to this file; starts after prebuild") fs.StringVar(&cfg.heapProfile, "heap-profile", "", "write Go heap profile to this file after execution") - fs.StringVar(&cfg.traceProfile, "trace-profile", "", "write Go runtime trace to this file; starts after prebuild when --prebuild-blocks is set") + fs.StringVar(&cfg.traceProfile, "trace-profile", "", "write Go runtime trace to this file; starts after prebuild") fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer, erc20-transfer, or snapshot-revert") fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") - fs.BoolVar(&cfg.prebuildBlocks, "prebuild-blocks", false, "generate all bounded blocks before starting executor workers") if err := fs.Parse(args); err != nil { return config{}, err @@ -229,6 +224,9 @@ func parseConfig(args []string) (config, error) { if cfg.workload == workloadSnapshotRevert && !txGasLimitSet { cfg.txGasLimit = defaultSnapshotRevertTxGasLimit } + if cfg.blocks == 0 { + return config{}, fmt.Errorf("blocks must be positive") + } if cfg.txsPerBlock <= 0 { return config{}, fmt.Errorf("txs-per-block must be positive") } @@ -256,9 +254,6 @@ func parseConfig(args []string) (config, error) { if cfg.executorWorkers <= 0 { return config{}, fmt.Errorf("executor-workers must be positive") } - if cfg.targetBlocksPerSec < 0 { - return config{}, fmt.Errorf("target-blocks-per-sec must be non-negative") - } if cfg.recipientConflictRate < 0 || cfg.recipientConflictRate > 1 { return config{}, fmt.Errorf("recipient-conflict-rate must be between 0 and 1") } @@ -307,9 +302,6 @@ func parseConfig(args []string) (config, error) { if cfg.transferValue.BitLen() > 256 { return config{}, fmt.Errorf("transfer-value-wei must fit in uint256") } - if cfg.prebuildBlocks && cfg.blocks == 0 { - return config{}, fmt.Errorf("prebuild-blocks requires --blocks > 0") - } if !cfg.disableGasPriceRule && cfg.gasPrice.Cmp(cfg.minGasPrice) < 0 { return config{}, fmt.Errorf("gas-price-wei must be greater than or equal to min-gas-price-wei unless disable-gas-price-rule is set") } @@ -443,10 +435,7 @@ func run(cfg config) (err error) { fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) } - if cfg.prebuildBlocks { - return runPrebuilt(ctx, cfg, state, workload, sinks, metrics) - } - return runStreaming(ctx, cfg, state, workload, sinks, metrics) + return runPrebuilt(ctx, cfg, state, workload, sinks, metrics) } type profileSession struct { @@ -595,54 +584,6 @@ func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { } } -func runStreaming(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) (err error) { - profiles, err := startProfiles(cfg) - if err != nil { - return err - } - - blocks := make(chan blockEnvelope, cfg.queueSize) - preparedBlocks := make(chan preparedBlockEnvelope, cfg.queueSize) - reportCtx, stopReporter := context.WithCancel(ctx) - reportDone := make(chan struct{}) - go func() { - defer close(reportDone) - reportLoop(reportCtx, cfg.reportInterval, metrics, preparedBlocks) - }() - - startedAt := time.Now() - group, groupCtx := errgroup.WithContext(ctx) - executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) - defer executor.Close() - metrics.recordResultPoolStats(executor.ResultPoolStats()) - group.Go(func() error { - defer close(blocks) - return produceBlocks(groupCtx, cfg, workload, blocks, metrics) - }) - group.Go(func() error { - defer close(preparedBlocks) - return prepareBlocks(groupCtx, cfg, executor, blocks, preparedBlocks, metrics) - }) - for workerID := 0; workerID < cfg.workers; workerID++ { - workerID := workerID - group.Go(func() error { - return executeBlocks(groupCtx, workerID, executor, preparedBlocks, metrics) - }) - } - - err = group.Wait() - metrics.recordResultPoolStats(executor.ResultPoolStats()) - stopReporter() - <-reportDone - - if errors.Is(err, context.Canceled) { - err = nil - } - printFinalReport(startedAt, metrics.snapshot()) - finishProfiles(profiles, &err) - return err -} - func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) (err error) { prebuildStartedAt := time.Now() prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) @@ -702,52 +643,8 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa return err } -func produceBlocks(ctx context.Context, cfg config, workload blockWorkload, out chan<- blockEnvelope, metrics *loadMetrics) error { - var limiter *rate.Limiter - if cfg.targetBlocksPerSec > 0 { - burst := int(math.Ceil(cfg.targetBlocksPerSec)) - if burst < 1 { - burst = 1 - } - limiter = rate.NewLimiter(rate.Limit(cfg.targetBlocksPerSec), burst) - } - - var nextBlock atomic.Uint64 - group, groupCtx := errgroup.WithContext(ctx) - for builderID := 0; builderID < cfg.builders; builderID++ { - group.Go(func() error { - for { - number := nextBlock.Add(1) - if cfg.blocks != 0 && number > cfg.blocks { - return nil - } - if limiter != nil { - if err := limiter.Wait(groupCtx); err != nil { - return nil - } - } - request, err := workload.buildBlock(groupCtx, number) - if err != nil { - if groupCtx.Err() != nil { - return nil - } - return err - } - block := blockEnvelope{number: number, request: request} - select { - case out <- block: - metrics.recordInput() - case <-groupCtx.Done(): - return nil - } - } - }) - } - return group.Wait() -} - func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorkload) ([]blockEnvelope, error) { - blockCount, err := checkedIntFromUint64("prebuild-blocks", cfg.blocks) + blockCount, err := checkedIntFromUint64("blocks", cfg.blocks) if err != nil { return nil, err } diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index c9888f5995..c8205604ee 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -27,6 +27,7 @@ import ( func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--txs-per-block=4", }) require.NoError(t, err) @@ -86,6 +87,7 @@ func TestTransferWorkloadOCCScenarios(t *testing.T) { t.Run(tc.name, func(t *testing.T) { args := append([]string{ "--metrics-addr=", + "--blocks=1", "--txs-per-block=4", "--gas-price-wei=0", "--min-gas-price-wei=0", @@ -156,6 +158,7 @@ func TestTransferWorkloadOCCScenarios(t *testing.T) { func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--workload=erc20-transfer", "--txs-per-block=4", "--gas-price-wei=0", @@ -207,6 +210,7 @@ func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { func TestSnapshotRevertWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--workload=snapshot-revert", "--txs-per-block=4", "--gas-price-wei=0", @@ -259,6 +263,7 @@ func TestSnapshotRevertWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { func TestTransferWorkloadRecipientConflictRate(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--txs-per-block=4", "--recipient-conflict-rate=0.5", "--gas-price-wei=0", @@ -317,20 +322,20 @@ func applyGeneratedStateChangeSet(state *generatedState, changeSet evmonly.State } } -func TestPrebuildBlocksRequiresBoundedRun(t *testing.T) { - _, err := parseConfig([]string{ - "--prebuild-blocks", - }) - require.ErrorContains(t, err, "prebuild-blocks requires --blocks > 0") +func TestBlocksRequiresBoundedRun(t *testing.T) { + _, err := parseConfig([]string{}) + require.ErrorContains(t, err, "blocks must be positive") } func TestRecipientConflictRateValidation(t *testing.T) { _, err := parseConfig([]string{ + "--blocks=1", "--recipient-conflict-rate=1.1", }) require.ErrorContains(t, err, "recipient-conflict-rate must be between 0 and 1") _, err = parseConfig([]string{ + "--blocks=1", "--recipient=0x0000000000000000000000000000000000000001", "--recipient-conflict-rate=0.5", }) @@ -339,12 +344,14 @@ func TestRecipientConflictRateValidation(t *testing.T) { func TestSameSenderValidation(t *testing.T) { _, err := parseConfig([]string{ + "--blocks=1", "--workload=erc20-transfer", "--same-sender", }) require.ErrorContains(t, err, "same-sender is only supported with transfer workload") _, err = parseConfig([]string{ + "--blocks=1", "--same-sender", "--txs-per-block=4", "--gas-price-wei=0", @@ -357,18 +364,21 @@ func TestSameSenderValidation(t *testing.T) { func TestSnapshotRevertValidation(t *testing.T) { _, err := parseConfig([]string{ + "--blocks=1", "--workload=snapshot-revert", "--recipient=0x0000000000000000000000000000000000000001", }) require.ErrorContains(t, err, "recipient is not supported with snapshot-revert workload") _, err = parseConfig([]string{ + "--blocks=1", "--workload=snapshot-revert", "--recipient-conflict-rate=0.5", }) require.ErrorContains(t, err, "recipient-conflict-rate is not supported with snapshot-revert workload") _, err = parseConfig([]string{ + "--blocks=1", "--workload=snapshot-revert", "--snapshot-revert-contract=0x0000000000000000000000000000000000000001", "--snapshot-revert-helper=0x0000000000000000000000000000000000000001", @@ -378,18 +388,21 @@ func TestSnapshotRevertValidation(t *testing.T) { func TestParseWorkersConfig(t *testing.T) { cfg, err := parseConfig([]string{ + "--blocks=1", "--prepare-workers=2", }) require.NoError(t, err) require.Equal(t, 1, cfg.parseWorkers) cfg, err = parseConfig([]string{ + "--blocks=1", "--prepare-workers=1", }) require.NoError(t, err) require.Equal(t, runtime.GOMAXPROCS(0), cfg.parseWorkers) cfg, err = parseConfig([]string{ + "--blocks=1", "--prepare-workers=8", "--parse-workers=3", }) @@ -397,6 +410,7 @@ func TestParseWorkersConfig(t *testing.T) { require.Equal(t, 3, cfg.parseWorkers) _, err = parseConfig([]string{ + "--blocks=1", "--parse-workers=-1", }) require.ErrorContains(t, err, "parse-workers must be non-negative") @@ -406,7 +420,6 @@ func TestRunPrebuiltBlocks(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", "--report-interval=0", - "--prebuild-blocks", "--blocks=2", "--txs-per-block=2", }) @@ -417,6 +430,7 @@ func TestRunPrebuiltBlocks(t *testing.T) { func TestPrepareBlocksCancelsWorkersOnOrderingInvariantError(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--report-interval=0", "--queue-size=1", "--prepare-workers=1", @@ -446,6 +460,7 @@ func TestPrepareBlocksCancelsWorkersOnOrderingInvariantError(t *testing.T) { func TestPrepareBlocksPrefersWorkerErrorOverOrderingDrainError(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--report-interval=0", "--queue-size=2", "--prepare-workers=1", @@ -466,6 +481,7 @@ func TestPrepareBlocksPrefersWorkerErrorOverOrderingDrainError(t *testing.T) { func TestPrepareBlocksDrainsPreparedBlocksAfterWorkersFinish(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--report-interval=0", "--queue-size=2", "--prepare-workers=1", @@ -496,7 +512,7 @@ func TestLoadMetricsRecordsOCCRerunsWithoutConflicts(t *testing.T) { } func TestBlockContextTimestampIsDeterministic(t *testing.T) { - cfg, err := parseConfig([]string{"--metrics-addr="}) + cfg, err := parseConfig([]string{"--metrics-addr=", "--blocks=1"}) require.NoError(t, err) require.Equal(t, defaultGenesisTimestamp+10, blockContext(cfg, 10).Time) @@ -506,6 +522,7 @@ func TestBlockContextTimestampIsDeterministic(t *testing.T) { func TestQueueSizeDefaultPersistQueueOverflowValidation(t *testing.T) { _, err := parseConfig([]string{ + "--blocks=1", "--queue-size=" + fmt.Sprint(math.MaxInt), }) require.ErrorContains(t, err, "queue-size must be at most") @@ -515,6 +532,7 @@ func TestFileResultSinkWritesRLPRecordsAndCleansUpOnCancel(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--result-sink=file", "--persist-dir=" + dir, "--persist-sync", @@ -575,6 +593,7 @@ func TestAsyncFileResultSinkReportsMetrics(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--result-sink=file", "--persist-dir=" + dir, "--persist-queue-size=1", @@ -600,6 +619,7 @@ func TestAsyncFileResultSinkStoresBlockResultAndReleases(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--result-sink=file", "--persist-dir=" + dir, "--persist-queue-size=1", @@ -673,6 +693,7 @@ func TestExecutorResultPoolReusesSlotsWithFileSink(t *testing.T) { dir := t.TempDir() cfg, err := parseConfig([]string{ "--metrics-addr=", + "--blocks=1", "--result-sink=file", "--persist-dir=" + dir, "--result-pool-size=1", @@ -713,7 +734,6 @@ func TestRunPrebuiltBlocksWithFileResultSinkCleansUp(t *testing.T) { cfg, err := parseConfig([]string{ "--metrics-addr=", "--report-interval=0", - "--prebuild-blocks", "--blocks=2", "--txs-per-block=2", "--gas-price-wei=0", @@ -802,6 +822,7 @@ func BenchmarkExecuteTransferBlock(b *testing.B) { b.Run(tc.name, func(b *testing.B) { args := append([]string{ "--metrics-addr=", + "--blocks=1", "--txs-per-block=1000", "--gas-price-wei=0", "--min-gas-price-wei=0", From bcfba730510f528732cf7f823d5cd4294b2a39ce Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 4 Aug 2026 15:55:20 +0800 Subject: [PATCH 29/34] split evm-only loadtest harness files --- giga/evmonly/cmd/evmonly-loadtest/config.go | 377 +++ giga/evmonly/cmd/evmonly-loadtest/main.go | 2274 ----------------- .../evmonly/cmd/evmonly-loadtest/main_test.go | 57 +- giga/evmonly/cmd/evmonly-loadtest/metrics.go | 593 +++++ giga/evmonly/cmd/evmonly-loadtest/pipeline.go | 389 +++ giga/evmonly/cmd/evmonly-loadtest/profiles.go | 117 + .../scenarios/erc20_transfer.go | 102 + .../cmd/evmonly-loadtest/scenarios/helpers.go | 118 + .../scenarios/snapshot_revert.go | 94 + .../evmonly-loadtest/scenarios/transfer.go | 86 + .../cmd/evmonly-loadtest/scenarios/types.go | 58 + giga/evmonly/cmd/evmonly-loadtest/sinks.go | 373 +++ giga/evmonly/cmd/evmonly-loadtest/state.go | 140 + 13 files changed, 2476 insertions(+), 2302 deletions(-) create mode 100644 giga/evmonly/cmd/evmonly-loadtest/config.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/metrics.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/pipeline.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/profiles.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/scenarios/helpers.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/scenarios/snapshot_revert.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/sinks.go create mode 100644 giga/evmonly/cmd/evmonly-loadtest/state.go diff --git a/giga/evmonly/cmd/evmonly-loadtest/config.go b/giga/evmonly/cmd/evmonly-loadtest/config.go new file mode 100644 index 0000000000..a02e9bde0c --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/config.go @@ -0,0 +1,377 @@ +package main + +import ( + "flag" + "fmt" + "math" + "math/big" + "os" + "runtime" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" +) + +const ( + defaultChainID = "713715" + defaultGasPriceWei = "1000000000" + defaultMinGasPriceWei = "1000000000" + defaultSenderBalance = "1000000000000000000" + defaultTransferValue = "1" + defaultERC20Contract = "0x000000000000000000000000000000000000e20c" + defaultSnapshotRevertContract = "0x0000000000000000000000000000000000005a90" + defaultSnapshotRevertHelper = "0x0000000000000000000000000000000000005a91" + defaultMetricsAddr = "127.0.0.1:9698" + defaultReportInterval = 5 * time.Second + defaultQueueSize = 64 + defaultTxGasLimit = 21_000 + defaultERC20TxGasLimit = 100_000 + defaultSnapshotRevertTxGasLimit = 100_000 + defaultTxsPerBlock = 1_000 + defaultPersistBuffer = 4 << 20 + defaultGenesisTimestamp = scenarios.DefaultGenesisTimestamp + defaultWorkerCount = 1 + defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" + workloadTransfer = scenarios.WorkloadTransfer + workloadERC20Transfer = scenarios.WorkloadERC20Transfer + workloadSnapshotRevert = scenarios.WorkloadSnapshotRevert + resultSinkDiscard = "discard" + resultSinkFile = "file" + resultSinkChangeSet = "changeset" + resultSinkReceipts = "receipts" +) + +type config struct { + blocks uint64 + txsPerBlock int + queueSize int + builders int + prepareWorkers int + parseWorkers int + workers int + executorWorkers int + reportInterval time.Duration + metricsAddr string + resultSink string + resultPoolSize int + persistDir string + persistSync bool + persistBufferSize int + persistQueueSize int + cpuProfile string + heapProfile string + traceProfile string + workload string + chainID *big.Int + gasPrice *big.Int + minGasPrice *big.Int + senderBalance *big.Int + transferValue *big.Int + txGasLimit uint64 + blockGasLimit uint64 + coinbase common.Address + erc20Contract common.Address + snapshotRevertContract common.Address + snapshotRevertHelper common.Address + fixedRecipient *common.Address + recipientConflictRate float64 + sameSender bool + disableGasPriceRule bool +} + +func scenarioConfig(cfg config) scenarios.Config { + return scenarios.Config{ + TxsPerBlock: cfg.txsPerBlock, + ChainID: cfg.chainID, + GasPrice: cfg.gasPrice, + SenderBalance: cfg.senderBalance, + TransferValue: cfg.transferValue, + TxGasLimit: cfg.txGasLimit, + BlockGasLimit: cfg.blockGasLimit, + Coinbase: cfg.coinbase, + ERC20Contract: cfg.erc20Contract, + SnapshotRevertContract: cfg.snapshotRevertContract, + SnapshotRevertHelper: cfg.snapshotRevertHelper, + FixedRecipient: cfg.fixedRecipient, + RecipientConflictRate: cfg.recipientConflictRate, + SameSender: cfg.sameSender, + } +} + +type blockEnvelope struct { + number uint64 + request evmonly.BlockRequest +} + +type preparedBlockEnvelope struct { + number uint64 + block evmonly.PreparedBlock +} + +func parseConfig(args []string) (config, error) { + cfg := config{} + fs := flag.NewFlagSet("evmonly-loadtest", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + chainID := fs.String("chain-id", defaultChainID, "EVM chain ID used to sign and execute transactions") + gasPrice := fs.String("gas-price-wei", defaultGasPriceWei, "legacy transaction gas price in wei") + minGasPrice := fs.String("min-gas-price-wei", defaultMinGasPriceWei, "executor minimum gas price in wei") + senderBalance := fs.String("sender-balance-wei", defaultSenderBalance, "generated sender genesis balance in wei") + transferValue := fs.String("transfer-value-wei", defaultTransferValue, "wei or token units transferred by each generated transaction") + coinbase := fs.String("coinbase", defaultCoinbaseAddress, "block coinbase address") + erc20Contract := fs.String("erc20-contract", defaultERC20Contract, "EVM address for the generated ERC20 transfer contract") + snapshotRevertContract := fs.String("snapshot-revert-contract", defaultSnapshotRevertContract, "EVM address for the generated snapshot-revert outer contract") + snapshotRevertHelper := fs.String("snapshot-revert-helper", defaultSnapshotRevertHelper, "EVM address for the generated snapshot-revert helper contract") + recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") + fs.Float64Var(&cfg.recipientConflictRate, "recipient-conflict-rate", 0, "fraction [0,1] of transactions per block paired onto shared recipients; 0 keeps recipients unique") + fs.BoolVar(&cfg.sameSender, "same-sender", false, "use one sender with sequential nonces for every transaction in a transfer block") + + fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to prebuild and execute; must be positive") + fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") + fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") + fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") + fs.IntVar(&cfg.prepareWorkers, "prepare-workers", defaultPrepareWorkers(), "parallel block preparation workers for transaction decode and sender recovery") + fs.IntVar(&cfg.parseWorkers, "parse-workers", 0, "parallel transaction decode/sender recovery workers inside each prepared block; 0 defaults to 1 when prepare-workers > 1, otherwise GOMAXPROCS") + fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") + fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") + fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") + fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") + fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") + fs.IntVar(&cfg.resultPoolSize, "result-pool-size", 0, "pooled executor BlockResult slots; 0 sizes for in-flight sink results, negative disables pooling") + fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files, removed at shutdown") + fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") + fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") + fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for async file persistence; 0 defaults to 2*queue-size") + fs.StringVar(&cfg.cpuProfile, "cpu-profile", "", "write Go CPU profile to this file; starts after prebuild") + fs.StringVar(&cfg.heapProfile, "heap-profile", "", "write Go heap profile to this file after execution") + fs.StringVar(&cfg.traceProfile, "trace-profile", "", "write Go runtime trace to this file; starts after prebuild") + fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer, erc20-transfer, or snapshot-revert") + fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") + fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") + fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") + + if err := fs.Parse(args); err != nil { + return config{}, err + } + var err error + if cfg.chainID, err = parsePositiveBig("chain-id", *chainID); err != nil { + return config{}, err + } + if cfg.gasPrice, err = parseNonNegativeBig("gas-price-wei", *gasPrice); err != nil { + return config{}, err + } + if cfg.minGasPrice, err = parseNonNegativeBig("min-gas-price-wei", *minGasPrice); err != nil { + return config{}, err + } + if cfg.senderBalance, err = parseNonNegativeBig("sender-balance-wei", *senderBalance); err != nil { + return config{}, err + } + if cfg.transferValue, err = parseNonNegativeBig("transfer-value-wei", *transferValue); err != nil { + return config{}, err + } + if !common.IsHexAddress(*coinbase) { + return config{}, fmt.Errorf("coinbase must be a hex EVM address") + } + cfg.coinbase = common.HexToAddress(*coinbase) + if *recipient != "" { + if !common.IsHexAddress(*recipient) { + return config{}, fmt.Errorf("recipient must be a hex EVM address") + } + addr := common.HexToAddress(*recipient) + cfg.fixedRecipient = &addr + } + if !common.IsHexAddress(*erc20Contract) { + return config{}, fmt.Errorf("erc20-contract must be a hex EVM address") + } + cfg.erc20Contract = common.HexToAddress(*erc20Contract) + if !common.IsHexAddress(*snapshotRevertContract) { + return config{}, fmt.Errorf("snapshot-revert-contract must be a hex EVM address") + } + cfg.snapshotRevertContract = common.HexToAddress(*snapshotRevertContract) + if !common.IsHexAddress(*snapshotRevertHelper) { + return config{}, fmt.Errorf("snapshot-revert-helper must be a hex EVM address") + } + cfg.snapshotRevertHelper = common.HexToAddress(*snapshotRevertHelper) + cfg.workload = strings.ToLower(strings.TrimSpace(cfg.workload)) + if cfg.workload != workloadTransfer && cfg.workload != workloadERC20Transfer && cfg.workload != workloadSnapshotRevert { + return config{}, fmt.Errorf("unsupported workload %q", cfg.workload) + } + txGasLimitSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "tx-gas-limit" { + txGasLimitSet = true + } + }) + if cfg.workload == workloadERC20Transfer && !txGasLimitSet { + cfg.txGasLimit = defaultERC20TxGasLimit + } + if cfg.workload == workloadSnapshotRevert && !txGasLimitSet { + cfg.txGasLimit = defaultSnapshotRevertTxGasLimit + } + if cfg.blocks == 0 { + return config{}, fmt.Errorf("blocks must be positive") + } + if cfg.txsPerBlock <= 0 { + return config{}, fmt.Errorf("txs-per-block must be positive") + } + if cfg.queueSize <= 0 { + return config{}, fmt.Errorf("queue-size must be positive") + } + if cfg.queueSize > math.MaxInt/2 { + return config{}, fmt.Errorf("queue-size must be at most %d", math.MaxInt/2) + } + if cfg.builders <= 0 { + return config{}, fmt.Errorf("builders must be positive") + } + if cfg.prepareWorkers <= 0 { + return config{}, fmt.Errorf("prepare-workers must be positive") + } + if cfg.parseWorkers < 0 { + return config{}, fmt.Errorf("parse-workers must be non-negative") + } + if cfg.parseWorkers == 0 { + cfg.parseWorkers = defaultParseWorkers(cfg.prepareWorkers) + } + if cfg.workers <= 0 { + return config{}, fmt.Errorf("workers must be positive") + } + if cfg.executorWorkers <= 0 { + return config{}, fmt.Errorf("executor-workers must be positive") + } + if cfg.recipientConflictRate < 0 || cfg.recipientConflictRate > 1 { + return config{}, fmt.Errorf("recipient-conflict-rate must be between 0 and 1") + } + if cfg.fixedRecipient != nil && cfg.recipientConflictRate != 0 { + return config{}, fmt.Errorf("recipient cannot be combined with recipient-conflict-rate") + } + if cfg.workload == workloadSnapshotRevert && cfg.fixedRecipient != nil { + return config{}, fmt.Errorf("recipient is not supported with snapshot-revert workload") + } + if cfg.workload == workloadSnapshotRevert && cfg.recipientConflictRate != 0 { + return config{}, fmt.Errorf("recipient-conflict-rate is not supported with snapshot-revert workload") + } + if cfg.sameSender && cfg.workload != workloadTransfer { + return config{}, fmt.Errorf("same-sender is only supported with transfer workload") + } + if cfg.workload == workloadSnapshotRevert && cfg.snapshotRevertContract == cfg.snapshotRevertHelper { + return config{}, fmt.Errorf("snapshot-revert-contract and snapshot-revert-helper must differ") + } + if cfg.reportInterval < 0 { + return config{}, fmt.Errorf("report-interval must be non-negative") + } + cfg.resultSink = strings.ToLower(strings.TrimSpace(cfg.resultSink)) + if cfg.resultSink != resultSinkDiscard && cfg.resultSink != resultSinkFile { + return config{}, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) + } + if cfg.persistBufferSize <= 0 { + return config{}, fmt.Errorf("persist-buffer-size must be positive") + } + if cfg.persistQueueSize < 0 { + return config{}, fmt.Errorf("persist-queue-size must be non-negative") + } + if cfg.persistQueueSize == 0 { + cfg.persistQueueSize = 2 * cfg.queueSize + } + if cfg.resultPoolSize < 0 { + cfg.resultPoolSize = 0 + } else if cfg.resultPoolSize == 0 { + cfg.resultPoolSize = defaultResultPoolSize(cfg) + } + if cfg.resultSink == resultSinkFile && strings.TrimSpace(cfg.persistDir) == "" { + return config{}, fmt.Errorf("persist-dir is required when result-sink=file") + } + if cfg.txGasLimit == 0 { + return config{}, fmt.Errorf("tx-gas-limit must be positive") + } + if cfg.transferValue.BitLen() > 256 { + return config{}, fmt.Errorf("transfer-value-wei must fit in uint256") + } + if !cfg.disableGasPriceRule && cfg.gasPrice.Cmp(cfg.minGasPrice) < 0 { + return config{}, fmt.Errorf("gas-price-wei must be greater than or equal to min-gas-price-wei unless disable-gas-price-rule is set") + } + requiredBalance := new(big.Int).Mul(new(big.Int).SetUint64(cfg.txGasLimit), cfg.gasPrice) + requiredBalanceReason := "max gas cost" + if cfg.workload == workloadTransfer { + requiredBalance.Add(requiredBalance, cfg.transferValue) + requiredBalanceReason = "transfer value plus max gas cost" + } + if cfg.sameSender { + txCount := new(big.Int).SetUint64(uint64(cfg.txsPerBlock)) //nolint:gosec // txsPerBlock is validated as positive above. + requiredBalance.Mul(requiredBalance, txCount) + requiredBalanceReason = "all same-sender transactions' transfer value plus max gas cost" + } + if cfg.senderBalance.Cmp(requiredBalance) < 0 { + return config{}, fmt.Errorf("sender-balance-wei must cover %s: need at least %s", requiredBalanceReason, requiredBalance.String()) + } + return cfg, nil +} + +func defaultPrepareWorkers() int { + return runtime.GOMAXPROCS(0) +} + +func defaultParseWorkers(prepareWorkers int) int { + if prepareWorkers > 1 { + return 1 + } + return runtime.GOMAXPROCS(0) +} + +func defaultExecutorWorkers() int { + workers := runtime.GOMAXPROCS(0) + if workers > 12 { + return 12 + } + return workers +} + +func defaultResultPoolSize(cfg config) int { + size := cfg.workers + 1 + if cfg.resultSink == resultSinkFile { + size += cfg.persistQueueSize + } + return size +} + +func executorConfig(cfg config) evmonly.Config { + return evmonly.Config{ + MinGasPrice: new(big.Int).Set(cfg.minGasPrice), + DisableGasPriceCheck: cfg.disableGasPriceRule, + OCCWorkers: cfg.executorWorkers, + ParseWorkers: cfg.parseWorkers, + BlockResultPoolSize: cfg.resultPoolSize, + } +} + +func parsePositiveBig(name, raw string) (*big.Int, error) { + v, err := parseBig(name, raw) + if err != nil { + return nil, err + } + if v.Sign() <= 0 { + return nil, fmt.Errorf("%s must be positive", name) + } + return v, nil +} + +func parseNonNegativeBig(name, raw string) (*big.Int, error) { + v, err := parseBig(name, raw) + if err != nil { + return nil, err + } + if v.Sign() < 0 { + return nil, fmt.Errorf("%s must be non-negative", name) + } + return v, nil +} + +func parseBig(name, raw string) (*big.Int, error) { + v, ok := new(big.Int).SetString(strings.TrimSpace(raw), 10) + if !ok { + return nil, fmt.Errorf("%s must be a base-10 integer", name) + } + return v, nil +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/main.go b/giga/evmonly/cmd/evmonly-loadtest/main.go index 103798b647..90c8a63c63 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main.go @@ -1,117 +1,10 @@ package main import ( - "bufio" - "context" - "crypto/ecdsa" - "encoding/binary" - "errors" - "flag" "fmt" - "math" - "math/big" - "net" - "net/http" "os" - "os/signal" - "path/filepath" - "runtime" - "runtime/pprof" - "runtime/trace" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - - "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/rlp" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" - "golang.org/x/sync/errgroup" - - "github.com/sei-protocol/sei-chain/giga/evmonly" -) - -const ( - defaultChainID = "713715" - defaultGasPriceWei = "1000000000" - defaultMinGasPriceWei = "1000000000" - defaultSenderBalance = "1000000000000000000" - defaultTransferValue = "1" - defaultERC20Contract = "0x000000000000000000000000000000000000e20c" - defaultSnapshotRevertContract = "0x0000000000000000000000000000000000005a90" - defaultSnapshotRevertHelper = "0x0000000000000000000000000000000000005a91" - defaultMetricsAddr = "127.0.0.1:9698" - defaultReportInterval = 5 * time.Second - defaultQueueSize = 64 - defaultTxGasLimit = 21_000 - defaultERC20TxGasLimit = 100_000 - defaultSnapshotRevertTxGasLimit = 100_000 - defaultTxsPerBlock = 1_000 - defaultPersistBuffer = 4 << 20 - defaultGenesisTimestamp = uint64(1_700_000_000) - defaultWorkerCount = 1 - defaultCoinbaseAddress = "0x00000000000000000000000000000000000000cb" - workloadTransfer = "transfer" - workloadERC20Transfer = "erc20-transfer" - workloadSnapshotRevert = "snapshot-revert" - resultSinkDiscard = "discard" - resultSinkFile = "file" - resultSinkChangeSet = "changeset" - resultSinkReceipts = "receipts" ) -type config struct { - blocks uint64 - txsPerBlock int - queueSize int - builders int - prepareWorkers int - parseWorkers int - workers int - executorWorkers int - reportInterval time.Duration - metricsAddr string - resultSink string - resultPoolSize int - persistDir string - persistSync bool - persistBufferSize int - persistQueueSize int - cpuProfile string - heapProfile string - traceProfile string - workload string - chainID *big.Int - gasPrice *big.Int - minGasPrice *big.Int - senderBalance *big.Int - transferValue *big.Int - txGasLimit uint64 - blockGasLimit uint64 - coinbase common.Address - erc20Contract common.Address - snapshotRevertContract common.Address - snapshotRevertHelper common.Address - fixedRecipient *common.Address - recipientConflictRate float64 - sameSender bool - disableGasPriceRule bool -} - -type blockEnvelope struct { - number uint64 - request evmonly.BlockRequest -} - -type preparedBlockEnvelope struct { - number uint64 - block evmonly.PreparedBlock -} - func main() { cfg, err := parseConfig(os.Args[1:]) if err != nil { @@ -123,2170 +16,3 @@ func main() { os.Exit(1) } } - -func parseConfig(args []string) (config, error) { - cfg := config{} - fs := flag.NewFlagSet("evmonly-loadtest", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - - chainID := fs.String("chain-id", defaultChainID, "EVM chain ID used to sign and execute transactions") - gasPrice := fs.String("gas-price-wei", defaultGasPriceWei, "legacy transaction gas price in wei") - minGasPrice := fs.String("min-gas-price-wei", defaultMinGasPriceWei, "executor minimum gas price in wei") - senderBalance := fs.String("sender-balance-wei", defaultSenderBalance, "generated sender genesis balance in wei") - transferValue := fs.String("transfer-value-wei", defaultTransferValue, "wei or token units transferred by each generated transaction") - coinbase := fs.String("coinbase", defaultCoinbaseAddress, "block coinbase address") - erc20Contract := fs.String("erc20-contract", defaultERC20Contract, "EVM address for the generated ERC20 transfer contract") - snapshotRevertContract := fs.String("snapshot-revert-contract", defaultSnapshotRevertContract, "EVM address for the generated snapshot-revert outer contract") - snapshotRevertHelper := fs.String("snapshot-revert-helper", defaultSnapshotRevertHelper, "EVM address for the generated snapshot-revert helper contract") - recipient := fs.String("recipient", "", "optional fixed transfer recipient; empty creates one recipient per tx") - fs.Float64Var(&cfg.recipientConflictRate, "recipient-conflict-rate", 0, "fraction [0,1] of transactions per block paired onto shared recipients; 0 keeps recipients unique") - fs.BoolVar(&cfg.sameSender, "same-sender", false, "use one sender with sequential nonces for every transaction in a transfer block") - - fs.Uint64Var(&cfg.blocks, "blocks", 0, "number of blocks to prebuild and execute; must be positive") - fs.IntVar(&cfg.txsPerBlock, "txs-per-block", defaultTxsPerBlock, "transactions generated per block") - fs.IntVar(&cfg.queueSize, "queue-size", defaultQueueSize, "buffered blocks waiting for executor workers") - fs.IntVar(&cfg.builders, "builders", runtime.GOMAXPROCS(0), "parallel block builder goroutines") - fs.IntVar(&cfg.prepareWorkers, "prepare-workers", defaultPrepareWorkers(), "parallel block preparation workers for transaction decode and sender recovery") - fs.IntVar(&cfg.parseWorkers, "parse-workers", 0, "parallel transaction decode/sender recovery workers inside each prepared block; 0 defaults to 1 when prepare-workers > 1, otherwise GOMAXPROCS") - fs.IntVar(&cfg.workers, "workers", defaultWorkerCount, "parallel executor workers") - fs.IntVar(&cfg.executorWorkers, "executor-workers", defaultExecutorWorkers(), "parallel OCC workers inside each executor") - fs.DurationVar(&cfg.reportInterval, "report-interval", defaultReportInterval, "stdout and rate-gauge reporting interval; 0 disables periodic reports") - fs.StringVar(&cfg.metricsAddr, "metrics-addr", defaultMetricsAddr, "Prometheus listen address; empty disables HTTP metrics") - fs.StringVar(&cfg.resultSink, "result-sink", resultSinkDiscard, "result sink mode: discard or file") - fs.IntVar(&cfg.resultPoolSize, "result-pool-size", 0, "pooled executor BlockResult slots; 0 sizes for in-flight sink results, negative disables pooling") - fs.StringVar(&cfg.persistDir, "persist-dir", "", "directory for --result-sink=file append-only changeset and receipt files, removed at shutdown") - fs.BoolVar(&cfg.persistSync, "persist-sync", false, "fsync persistent result files from the async sink writer") - fs.IntVar(&cfg.persistBufferSize, "persist-buffer-size", defaultPersistBuffer, "buffer size in bytes for --result-sink=file") - fs.IntVar(&cfg.persistQueueSize, "persist-queue-size", 0, "record queue size for async file persistence; 0 defaults to 2*queue-size") - fs.StringVar(&cfg.cpuProfile, "cpu-profile", "", "write Go CPU profile to this file; starts after prebuild") - fs.StringVar(&cfg.heapProfile, "heap-profile", "", "write Go heap profile to this file after execution") - fs.StringVar(&cfg.traceProfile, "trace-profile", "", "write Go runtime trace to this file; starts after prebuild") - fs.StringVar(&cfg.workload, "workload", workloadTransfer, "workload type: transfer, erc20-transfer, or snapshot-revert") - fs.Uint64Var(&cfg.txGasLimit, "tx-gas-limit", defaultTxGasLimit, "gas limit for each generated transaction") - fs.Uint64Var(&cfg.blockGasLimit, "block-gas-limit", 0, "block gas limit; 0 lets the executor use its maximum") - fs.BoolVar(&cfg.disableGasPriceRule, "disable-gas-price-rule", false, "disable the executor min-gas-price validity rule") - - if err := fs.Parse(args); err != nil { - return config{}, err - } - var err error - if cfg.chainID, err = parsePositiveBig("chain-id", *chainID); err != nil { - return config{}, err - } - if cfg.gasPrice, err = parseNonNegativeBig("gas-price-wei", *gasPrice); err != nil { - return config{}, err - } - if cfg.minGasPrice, err = parseNonNegativeBig("min-gas-price-wei", *minGasPrice); err != nil { - return config{}, err - } - if cfg.senderBalance, err = parseNonNegativeBig("sender-balance-wei", *senderBalance); err != nil { - return config{}, err - } - if cfg.transferValue, err = parseNonNegativeBig("transfer-value-wei", *transferValue); err != nil { - return config{}, err - } - if !common.IsHexAddress(*coinbase) { - return config{}, fmt.Errorf("coinbase must be a hex EVM address") - } - cfg.coinbase = common.HexToAddress(*coinbase) - if *recipient != "" { - if !common.IsHexAddress(*recipient) { - return config{}, fmt.Errorf("recipient must be a hex EVM address") - } - addr := common.HexToAddress(*recipient) - cfg.fixedRecipient = &addr - } - if !common.IsHexAddress(*erc20Contract) { - return config{}, fmt.Errorf("erc20-contract must be a hex EVM address") - } - cfg.erc20Contract = common.HexToAddress(*erc20Contract) - if !common.IsHexAddress(*snapshotRevertContract) { - return config{}, fmt.Errorf("snapshot-revert-contract must be a hex EVM address") - } - cfg.snapshotRevertContract = common.HexToAddress(*snapshotRevertContract) - if !common.IsHexAddress(*snapshotRevertHelper) { - return config{}, fmt.Errorf("snapshot-revert-helper must be a hex EVM address") - } - cfg.snapshotRevertHelper = common.HexToAddress(*snapshotRevertHelper) - cfg.workload = strings.ToLower(strings.TrimSpace(cfg.workload)) - if cfg.workload != workloadTransfer && cfg.workload != workloadERC20Transfer && cfg.workload != workloadSnapshotRevert { - return config{}, fmt.Errorf("unsupported workload %q", cfg.workload) - } - txGasLimitSet := false - fs.Visit(func(f *flag.Flag) { - if f.Name == "tx-gas-limit" { - txGasLimitSet = true - } - }) - if cfg.workload == workloadERC20Transfer && !txGasLimitSet { - cfg.txGasLimit = defaultERC20TxGasLimit - } - if cfg.workload == workloadSnapshotRevert && !txGasLimitSet { - cfg.txGasLimit = defaultSnapshotRevertTxGasLimit - } - if cfg.blocks == 0 { - return config{}, fmt.Errorf("blocks must be positive") - } - if cfg.txsPerBlock <= 0 { - return config{}, fmt.Errorf("txs-per-block must be positive") - } - if cfg.queueSize <= 0 { - return config{}, fmt.Errorf("queue-size must be positive") - } - if cfg.queueSize > math.MaxInt/2 { - return config{}, fmt.Errorf("queue-size must be at most %d", math.MaxInt/2) - } - if cfg.builders <= 0 { - return config{}, fmt.Errorf("builders must be positive") - } - if cfg.prepareWorkers <= 0 { - return config{}, fmt.Errorf("prepare-workers must be positive") - } - if cfg.parseWorkers < 0 { - return config{}, fmt.Errorf("parse-workers must be non-negative") - } - if cfg.parseWorkers == 0 { - cfg.parseWorkers = defaultParseWorkers(cfg.prepareWorkers) - } - if cfg.workers <= 0 { - return config{}, fmt.Errorf("workers must be positive") - } - if cfg.executorWorkers <= 0 { - return config{}, fmt.Errorf("executor-workers must be positive") - } - if cfg.recipientConflictRate < 0 || cfg.recipientConflictRate > 1 { - return config{}, fmt.Errorf("recipient-conflict-rate must be between 0 and 1") - } - if cfg.fixedRecipient != nil && cfg.recipientConflictRate != 0 { - return config{}, fmt.Errorf("recipient cannot be combined with recipient-conflict-rate") - } - if cfg.workload == workloadSnapshotRevert && cfg.fixedRecipient != nil { - return config{}, fmt.Errorf("recipient is not supported with snapshot-revert workload") - } - if cfg.workload == workloadSnapshotRevert && cfg.recipientConflictRate != 0 { - return config{}, fmt.Errorf("recipient-conflict-rate is not supported with snapshot-revert workload") - } - if cfg.sameSender && cfg.workload != workloadTransfer { - return config{}, fmt.Errorf("same-sender is only supported with transfer workload") - } - if cfg.workload == workloadSnapshotRevert && cfg.snapshotRevertContract == cfg.snapshotRevertHelper { - return config{}, fmt.Errorf("snapshot-revert-contract and snapshot-revert-helper must differ") - } - if cfg.reportInterval < 0 { - return config{}, fmt.Errorf("report-interval must be non-negative") - } - cfg.resultSink = strings.ToLower(strings.TrimSpace(cfg.resultSink)) - if cfg.resultSink != resultSinkDiscard && cfg.resultSink != resultSinkFile { - return config{}, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) - } - if cfg.persistBufferSize <= 0 { - return config{}, fmt.Errorf("persist-buffer-size must be positive") - } - if cfg.persistQueueSize < 0 { - return config{}, fmt.Errorf("persist-queue-size must be non-negative") - } - if cfg.persistQueueSize == 0 { - cfg.persistQueueSize = 2 * cfg.queueSize - } - if cfg.resultPoolSize < 0 { - cfg.resultPoolSize = 0 - } else if cfg.resultPoolSize == 0 { - cfg.resultPoolSize = defaultResultPoolSize(cfg) - } - if cfg.resultSink == resultSinkFile && strings.TrimSpace(cfg.persistDir) == "" { - return config{}, fmt.Errorf("persist-dir is required when result-sink=file") - } - if cfg.txGasLimit == 0 { - return config{}, fmt.Errorf("tx-gas-limit must be positive") - } - if cfg.transferValue.BitLen() > 256 { - return config{}, fmt.Errorf("transfer-value-wei must fit in uint256") - } - if !cfg.disableGasPriceRule && cfg.gasPrice.Cmp(cfg.minGasPrice) < 0 { - return config{}, fmt.Errorf("gas-price-wei must be greater than or equal to min-gas-price-wei unless disable-gas-price-rule is set") - } - requiredBalance := new(big.Int).Mul(new(big.Int).SetUint64(cfg.txGasLimit), cfg.gasPrice) - requiredBalanceReason := "max gas cost" - if cfg.workload == workloadTransfer { - requiredBalance.Add(requiredBalance, cfg.transferValue) - requiredBalanceReason = "transfer value plus max gas cost" - } - if cfg.sameSender { - txCount := new(big.Int).SetUint64(uint64(cfg.txsPerBlock)) //nolint:gosec // txsPerBlock is validated as positive above. - requiredBalance.Mul(requiredBalance, txCount) - requiredBalanceReason = "all same-sender transactions' transfer value plus max gas cost" - } - if cfg.senderBalance.Cmp(requiredBalance) < 0 { - return config{}, fmt.Errorf("sender-balance-wei must cover %s: need at least %s", requiredBalanceReason, requiredBalance.String()) - } - return cfg, nil -} - -func defaultPrepareWorkers() int { - return runtime.GOMAXPROCS(0) -} - -func defaultParseWorkers(prepareWorkers int) int { - if prepareWorkers > 1 { - return 1 - } - return runtime.GOMAXPROCS(0) -} - -func defaultExecutorWorkers() int { - workers := runtime.GOMAXPROCS(0) - if workers > 12 { - return 12 - } - return workers -} - -func defaultResultPoolSize(cfg config) int { - size := cfg.workers + 1 - if cfg.resultSink == resultSinkFile { - size += cfg.persistQueueSize - } - return size -} - -func executorConfig(cfg config) evmonly.Config { - return evmonly.Config{ - MinGasPrice: new(big.Int).Set(cfg.minGasPrice), - DisableGasPriceCheck: cfg.disableGasPriceRule, - OCCWorkers: cfg.executorWorkers, - ParseWorkers: cfg.parseWorkers, - BlockResultPoolSize: cfg.resultPoolSize, - } -} - -func parsePositiveBig(name, raw string) (*big.Int, error) { - v, err := parseBig(name, raw) - if err != nil { - return nil, err - } - if v.Sign() <= 0 { - return nil, fmt.Errorf("%s must be positive", name) - } - return v, nil -} - -func parseNonNegativeBig(name, raw string) (*big.Int, error) { - v, err := parseBig(name, raw) - if err != nil { - return nil, err - } - if v.Sign() < 0 { - return nil, fmt.Errorf("%s must be non-negative", name) - } - return v, nil -} - -func parseBig(name, raw string) (*big.Int, error) { - v, ok := new(big.Int).SetString(strings.TrimSpace(raw), 10) - if !ok { - return nil, fmt.Errorf("%s must be a base-10 integer", name) - } - return v, nil -} - -func run(cfg config) (err error) { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - state := newGeneratedState() - workload, err := newWorkload(cfg, state) - if err != nil { - return err - } - registry := prometheus.NewRegistry() - metrics := newLoadMetrics(registry) - sinks, err := newResultSinks(cfg, metrics) - if err != nil { - return err - } - defer func() { - closeStartedAt := time.Now() - if closeErr := sinks.Close(); closeErr != nil { - if err != nil { - fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink close: %v\n", closeErr) - return - } - err = closeErr - } - if cfg.resultSink == resultSinkFile { - printResultSinkReport(time.Since(closeStartedAt), metrics.snapshot()) - } - }() - stopSinkSignalCleanup := cleanupSinksOnContextCancel(ctx, sinks) - defer stopSinkSignalCleanup() - - var server *metricsServer - if cfg.metricsAddr != "" { - var err error - server, err = startMetricsServer(cfg.metricsAddr, registry) - if err != nil { - return err - } - defer func() { - if err := server.stop(3 * time.Second); err != nil { - fmt.Fprintf(os.Stderr, "evmonly-loadtest: metrics server shutdown: %v\n", err) - } - }() - fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) - } - - return runPrebuilt(ctx, cfg, state, workload, sinks, metrics) -} - -type profileSession struct { - cpuFile *os.File - traceFile *os.File - heapPath string - cpuActive bool - traceActive bool - heapComplete bool -} - -func startProfiles(cfg config) (*profileSession, error) { - if cfg.cpuProfile == "" && cfg.heapProfile == "" && cfg.traceProfile == "" { - return nil, nil - } - session := &profileSession{heapPath: cfg.heapProfile} - if cfg.cpuProfile != "" { - file, err := createProfileFile(cfg.cpuProfile) - if err != nil { - return nil, err - } - session.cpuFile = file - if err := pprof.StartCPUProfile(file); err != nil { - _ = session.Close() - return nil, fmt.Errorf("start CPU profile: %w", err) - } - session.cpuActive = true - } - if cfg.traceProfile != "" { - file, err := createProfileFile(cfg.traceProfile) - if err != nil { - _ = session.Close() - return nil, err - } - session.traceFile = file - if err := trace.Start(file); err != nil { - _ = session.Close() - return nil, fmt.Errorf("start runtime trace: %w", err) - } - session.traceActive = true - } - return session, nil -} - -func createProfileFile(path string) (*os.File, error) { - if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { - return nil, fmt.Errorf("create profile dir for %s: %w", path, err) - } - file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // profile output path is an explicit CLI argument. - if err != nil { - return nil, fmt.Errorf("create profile file %s: %w", path, err) - } - return file, nil -} - -func (p *profileSession) Close() error { - if p == nil { - return nil - } - var errs []error - if p.traceActive { - trace.Stop() - p.traceActive = false - } - if p.cpuActive { - pprof.StopCPUProfile() - p.cpuActive = false - } - if p.cpuFile != nil { - errs = append(errs, p.cpuFile.Close()) - p.cpuFile = nil - } - if p.traceFile != nil { - errs = append(errs, p.traceFile.Close()) - p.traceFile = nil - } - if p.heapPath != "" && !p.heapComplete { - errs = append(errs, writeHeapProfile(p.heapPath)) - p.heapComplete = true - } - return errors.Join(errs...) -} - -func writeHeapProfile(path string) (err error) { - file, err := createProfileFile(path) - if err != nil { - return err - } - defer func() { - err = errors.Join(err, file.Close()) - }() - runtime.GC() - if err := pprof.WriteHeapProfile(file); err != nil { - return fmt.Errorf("write heap profile %s: %w", path, err) - } - return nil -} - -func finishProfiles(session *profileSession, err *error) { - if closeErr := session.Close(); closeErr != nil { - if *err != nil { - fmt.Fprintf(os.Stderr, "evmonly-loadtest: write profile: %v\n", closeErr) - return - } - *err = closeErr - } -} - -func cleanupSinksOnContextCancel(ctx context.Context, sinks *resultSinks) func() { - cleanupCtx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - select { - case <-ctx.Done(): - if err := sinks.Cleanup(); err != nil { - fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink cleanup: %v\n", err) - } - case <-cleanupCtx.Done(): - } - }() - return func() { - select { - case <-ctx.Done(): - default: - cancel() - } - <-done - } -} - -type blockWorkload interface { - buildBlock(context.Context, uint64) (evmonly.BlockRequest, error) -} - -func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { - switch cfg.workload { - case workloadTransfer: - return newTransferWorkload(cfg, state), nil - case workloadERC20Transfer: - return newERC20TransferWorkload(cfg, state), nil - case workloadSnapshotRevert: - return newSnapshotRevertWorkload(cfg, state), nil - default: - return nil, fmt.Errorf("unsupported workload %q", cfg.workload) - } -} - -func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) (err error) { - prebuildStartedAt := time.Now() - prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) - if err != nil { - return err - } - state.Freeze() - prebuildElapsed := time.Since(prebuildStartedAt) - printPrebuildReport(prebuildElapsed, prebuilt, cfg.txsPerBlock) - - profiles, err := startProfiles(cfg) - if err != nil { - return err - } - - blocks := make(chan blockEnvelope, cfg.queueSize) - preparedBlocks := make(chan preparedBlockEnvelope, cfg.queueSize) - reportCtx, stopReporter := context.WithCancel(ctx) - reportDone := make(chan struct{}) - go func() { - defer close(reportDone) - reportLoop(reportCtx, cfg.reportInterval, metrics, preparedBlocks) - }() - - startedAt := time.Now() - group, groupCtx := errgroup.WithContext(ctx) - executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) - defer executor.Close() - metrics.recordResultPoolStats(executor.ResultPoolStats()) - group.Go(func() error { - defer close(blocks) - return feedPrebuiltBlocks(groupCtx, prebuilt, blocks, metrics) - }) - group.Go(func() error { - defer close(preparedBlocks) - return prepareBlocks(groupCtx, cfg, executor, blocks, preparedBlocks, metrics) - }) - for workerID := 0; workerID < cfg.workers; workerID++ { - workerID := workerID - group.Go(func() error { - return executeBlocks(groupCtx, workerID, executor, preparedBlocks, metrics) - }) - } - - err = group.Wait() - metrics.recordResultPoolStats(executor.ResultPoolStats()) - stopReporter() - <-reportDone - - if errors.Is(err, context.Canceled) { - err = nil - } - printFinalReport(startedAt, metrics.snapshot()) - // Drop raw block retention before heap profiling forces a GC. - prebuilt = nil - finishProfiles(profiles, &err) - return err -} - -func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorkload) ([]blockEnvelope, error) { - blockCount, err := checkedIntFromUint64("blocks", cfg.blocks) - if err != nil { - return nil, err - } - prebuilt := make([]blockEnvelope, blockCount) - var nextBlock atomic.Uint64 - group, groupCtx := errgroup.WithContext(ctx) - for builderID := 0; builderID < cfg.builders; builderID++ { - group.Go(func() error { - for { - number := nextBlock.Add(1) - if number > cfg.blocks { - return nil - } - request, err := workload.buildBlock(groupCtx, number) - if err != nil { - if groupCtx.Err() != nil { - return nil - } - return err - } - prebuilt[number-1] = blockEnvelope{ - number: number, - request: request, - } - } - }) - } - if err := group.Wait(); err != nil { - return nil, err - } - return prebuilt, nil -} - -func feedPrebuiltBlocks(ctx context.Context, prebuilt []blockEnvelope, out chan<- blockEnvelope, metrics *loadMetrics) error { - for _, block := range prebuilt { - select { - case out <- block: - metrics.recordInput() - case <-ctx.Done(): - return nil - } - } - return nil -} - -func prepareBlocks( - ctx context.Context, - cfg config, - executor evmonly.PreparedBlockExecutor, - blocks <-chan blockEnvelope, - out chan<- preparedBlockEnvelope, - metrics *loadMetrics, -) error { - unordered := make(chan preparedBlockEnvelope, cfg.queueSize) - workerCtx, cancelWorkers := context.WithCancel(ctx) - defer cancelWorkers() - group, groupCtx := errgroup.WithContext(workerCtx) - for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { - workerID := workerID - group.Go(func() error { - for { - select { - case <-groupCtx.Done(): - return nil - case block, ok := <-blocks: - if !ok { - return nil - } - prepared, err := executor.PrepareBlock(groupCtx, block.request) - if err != nil { - if groupCtx.Err() != nil { - return nil - } - metrics.recordPrepareError() - return fmt.Errorf("prepare worker %d prepare block %d: %w", workerID, block.number, err) - } - select { - case unordered <- preparedBlockEnvelope{number: block.number, block: prepared}: - metrics.recordPrepared(len(prepared.Txs)) - case <-groupCtx.Done(): - return nil - } - } - } - }) - } - done := make(chan error, 1) - go func() { - err := group.Wait() - close(unordered) - done <- err - }() - orderErr := forwardPreparedBlocksInOrder(ctx, unordered, out) - if orderErr != nil { - cancelWorkers() - } - workerErr := <-done - if workerErr != nil { - return errors.Join(workerErr, orderErr) - } - if orderErr != nil { - return orderErr - } - return nil -} - -func forwardPreparedBlocksInOrder(ctx context.Context, in <-chan preparedBlockEnvelope, out chan<- preparedBlockEnvelope) error { - next := uint64(1) - pending := make(map[uint64]preparedBlockEnvelope) - for { - for { - block, ok := pending[next] - if !ok { - break - } - if err := sendPreparedBlock(ctx, out, block); err != nil { - return err - } - delete(pending, next) - next++ - } - - select { - case <-ctx.Done(): - return nil - case block, ok := <-in: - if !ok { - for len(pending) > 0 { - block, ok := pending[next] - if !ok { - if ctx.Err() != nil { - return nil - } - return fmt.Errorf("prepared block stream closed before block %d", next) - } - if err := sendPreparedBlock(ctx, out, block); err != nil { - return err - } - delete(pending, next) - next++ - } - return nil - } - if block.number < next { - return fmt.Errorf("prepared block %d arrived after block %d", block.number, next-1) - } - if block.number == next { - if err := sendPreparedBlock(ctx, out, block); err != nil { - return err - } - next++ - continue - } - if _, exists := pending[block.number]; exists { - return fmt.Errorf("duplicate prepared block %d", block.number) - } - pending[block.number] = block - } - } -} - -func sendPreparedBlock(ctx context.Context, out chan<- preparedBlockEnvelope, block preparedBlockEnvelope) error { - select { - case out <- block: - return nil - case <-ctx.Done(): - return nil - } -} - -func checkedIntFromUint64(name string, value uint64) (int, error) { - if value > maxIntAsUint64() { - return 0, fmt.Errorf("%s cannot allocate %d blocks on this platform", name, value) - } - return int(value), nil //nolint:gosec // checked against max int above. -} - -func maxIntAsUint64() uint64 { - return uint64(^uint(0) >> 1) -} - -func uint64FromNonNegativeInt(value int) uint64 { - if value < 0 { - panic("negative integer cannot be converted to uint64") - } - return uint64(value) //nolint:gosec // negative values are rejected above. -} - -func uint64FromNonNegativeInt64(value int64) uint64 { - if value < 0 { - return 0 - } - return uint64(value) //nolint:gosec // negative values are rejected above. -} - -func durationFromUint64Nanos(nanos uint64) time.Duration { - const maxDurationNanos = uint64(1<<63 - 1) - if nanos > maxDurationNanos { - return time.Duration(maxDurationNanos) //nolint:gosec // capped to max int64 duration by construction. - } - return time.Duration(nanos) //nolint:gosec // capped to max int64 duration above. -} - -func executeBlocks( - ctx context.Context, - workerID int, - executor *evmonly.Executor, - blocks <-chan preparedBlockEnvelope, - metrics *loadMetrics, -) error { - for { - select { - case <-ctx.Done(): - return nil - case block, ok := <-blocks: - if !ok { - return nil - } - result, err := executor.ExecutePreparedBlock(ctx, block.block) - if err != nil { - if ctx.Err() != nil { - return nil - } - metrics.recordExecutionError() - return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) - } - metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) - result.Release() - metrics.recordResultPoolStats(executor.ResultPoolStats()) - } - } -} - -type transferWorkload struct { - cfg config - state *generatedState - signer ethtypes.Signer - conflictParticipants int - accountCursor atomic.Uint64 -} - -func newTransferWorkload(cfg config, state *generatedState) *transferWorkload { - return &transferWorkload{ - cfg: cfg, - state: state, - signer: ethtypes.LatestSignerForChainID(cfg.chainID), - conflictParticipants: recipientConflictParticipants(cfg.txsPerBlock, cfg.recipientConflictRate), - } -} - -func (w *transferWorkload) buildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { - txs := make([][]byte, w.cfg.txsPerBlock) - for i := 0; i < w.cfg.txsPerBlock; i++ { - select { - case <-ctx.Done(): - return evmonly.BlockRequest{}, ctx.Err() - default: - } - accountIndex := w.accountCursor.Add(1) - senderIndex := accountIndex - nonce := uint64(0) - if w.cfg.sameSender { - senderIndex = number - nonce = uint64(i) //nolint:gosec // i is bounded by txsPerBlock. - } - recipient := w.recipient(number, i, accountIndex) - raw, sender, err := w.buildTransferTx(senderIndex, nonce, recipient) - if err != nil { - return evmonly.BlockRequest{}, err - } - w.state.SetBalance(sender, w.cfg.senderBalance) - txs[i] = raw - } - return evmonly.BlockRequest{ - Context: blockContext(w.cfg, number), - Txs: txs, - }, nil -} - -func (w *transferWorkload) buildTransferTx(accountIndex, nonce uint64, recipient common.Address) ([]byte, common.Address, error) { - key, err := deterministicPrivateKey(accountIndex) - if err != nil { - return nil, common.Address{}, err - } - sender := crypto.PubkeyToAddress(key.PublicKey) - tx := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: nonce, - GasPrice: new(big.Int).Set(w.cfg.gasPrice), - Gas: w.cfg.txGasLimit, - To: &recipient, - Value: new(big.Int).Set(w.cfg.transferValue), - }) - signed, err := ethtypes.SignTx(tx, w.signer, key) - if err != nil { - return nil, common.Address{}, err - } - raw, err := signed.MarshalBinary() - if err != nil { - return nil, common.Address{}, err - } - return raw, sender, nil -} - -func (w *transferWorkload) recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { - return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-recipient", "sei-evmonly-loadtest-conflict-recipient", blockNumber, txIndex, accountIndex) -} - -type erc20TransferWorkload struct { - cfg config - state *generatedState - signer ethtypes.Signer - conflictParticipants int - accountCursor atomic.Uint64 -} - -var ( - erc20TransferSelector = [4]byte{0xa9, 0x05, 0x9c, 0xbb} - // Minimal ERC20-like runtime for transfer(address,uint256), with balances at - // storage slot 0 and a standard Transfer(address,address,uint256) log. - erc20TransferRuntimeCode = common.FromHex("0x60003560e01c63a9059cbb1460145760006000fd5b60243560043533600052600060205260406000208054831060805780548303905580600052600060205260406000208054830190558160005280337fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60206000a3600160005260206000f35b60006000fd") -) - -func newERC20TransferWorkload(cfg config, state *generatedState) *erc20TransferWorkload { - state.SetCode(cfg.erc20Contract, erc20TransferRuntimeCode) - return &erc20TransferWorkload{ - cfg: cfg, - state: state, - signer: ethtypes.LatestSignerForChainID(cfg.chainID), - conflictParticipants: recipientConflictParticipants(cfg.txsPerBlock, cfg.recipientConflictRate), - } -} - -func (w *erc20TransferWorkload) buildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { - txs := make([][]byte, w.cfg.txsPerBlock) - for i := 0; i < w.cfg.txsPerBlock; i++ { - select { - case <-ctx.Done(): - return evmonly.BlockRequest{}, ctx.Err() - default: - } - accountIndex := w.accountCursor.Add(1) - recipient := w.recipient(number, i, accountIndex) - raw, sender, err := w.buildTransferTx(accountIndex, recipient) - if err != nil { - return evmonly.BlockRequest{}, err - } - w.state.SetBalance(sender, w.cfg.senderBalance) - w.state.SetState(w.cfg.erc20Contract, erc20BalanceSlot(sender), common.BigToHash(w.cfg.transferValue)) - txs[i] = raw - } - return evmonly.BlockRequest{ - Context: blockContext(w.cfg, number), - Txs: txs, - }, nil -} - -func (w *erc20TransferWorkload) buildTransferTx(accountIndex uint64, recipient common.Address) ([]byte, common.Address, error) { - key, err := deterministicPrivateKey(accountIndex) - if err != nil { - return nil, common.Address{}, err - } - sender := crypto.PubkeyToAddress(key.PublicKey) - tx := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: 0, - GasPrice: new(big.Int).Set(w.cfg.gasPrice), - Gas: w.cfg.txGasLimit, - To: &w.cfg.erc20Contract, - Value: new(big.Int), - Data: erc20TransferCalldata(recipient, w.cfg.transferValue), - }) - signed, err := ethtypes.SignTx(tx, w.signer, key) - if err != nil { - return nil, common.Address{}, err - } - raw, err := signed.MarshalBinary() - if err != nil { - return nil, common.Address{}, err - } - return raw, sender, nil -} - -func (w *erc20TransferWorkload) recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { - return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-erc20-recipient", "sei-evmonly-loadtest-erc20-conflict-recipient", blockNumber, txIndex, accountIndex) -} - -type snapshotRevertWorkload struct { - cfg config - state *generatedState - signer ethtypes.Signer - accountCursor atomic.Uint64 -} - -var ( - // The outer runtime stores 1 at storage[CALLER], delegatecalls the helper - // address encoded in calldata, ignores the helper's reverted status, and - // returns successfully. - snapshotRevertOuterRuntimeCode = common.FromHex("0x6001335560006000600060006000355af450600160005260206000f3") - // The helper runtime runs under DELEGATECALL, overwrites storage[CALLER] - // with 2 in the outer storage context, then executes REVERT. - snapshotRevertHelperRuntimeCode = common.FromHex("0x6002335560006000fd") -) - -func newSnapshotRevertWorkload(cfg config, state *generatedState) *snapshotRevertWorkload { - state.SetCode(cfg.snapshotRevertContract, snapshotRevertOuterRuntimeCode) - state.SetCode(cfg.snapshotRevertHelper, snapshotRevertHelperRuntimeCode) - return &snapshotRevertWorkload{ - cfg: cfg, - state: state, - signer: ethtypes.LatestSignerForChainID(cfg.chainID), - } -} - -func (w *snapshotRevertWorkload) buildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { - txs := make([][]byte, w.cfg.txsPerBlock) - for i := 0; i < w.cfg.txsPerBlock; i++ { - select { - case <-ctx.Done(): - return evmonly.BlockRequest{}, ctx.Err() - default: - } - accountIndex := w.accountCursor.Add(1) - raw, sender, err := w.buildCallTx(accountIndex) - if err != nil { - return evmonly.BlockRequest{}, err - } - w.state.SetBalance(sender, w.cfg.senderBalance) - txs[i] = raw - } - return evmonly.BlockRequest{ - Context: blockContext(w.cfg, number), - Txs: txs, - }, nil -} - -func (w *snapshotRevertWorkload) buildCallTx(accountIndex uint64) ([]byte, common.Address, error) { - key, err := deterministicPrivateKey(accountIndex) - if err != nil { - return nil, common.Address{}, err - } - sender := crypto.PubkeyToAddress(key.PublicKey) - tx := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: 0, - GasPrice: new(big.Int).Set(w.cfg.gasPrice), - Gas: w.cfg.txGasLimit, - To: &w.cfg.snapshotRevertContract, - Value: new(big.Int), - Data: addressCalldata(w.cfg.snapshotRevertHelper), - }) - signed, err := ethtypes.SignTx(tx, w.signer, key) - if err != nil { - return nil, common.Address{}, err - } - raw, err := signed.MarshalBinary() - if err != nil { - return nil, common.Address{}, err - } - return raw, sender, nil -} - -func erc20TransferCalldata(recipient common.Address, amount *big.Int) []byte { - data := make([]byte, 4+32+32) - copy(data[:4], erc20TransferSelector[:]) - copy(data[4+12:36], recipient.Bytes()) - amount.FillBytes(data[36:68]) - return data -} - -func addressCalldata(addr common.Address) []byte { - data := make([]byte, 32) - copy(data[12:], addr.Bytes()) - return data -} - -func erc20BalanceSlot(owner common.Address) common.Hash { - var encoded [64]byte - copy(encoded[12:32], owner.Bytes()) - return crypto.Keccak256Hash(encoded[:]) -} - -func snapshotRevertStorageSlot(sender common.Address) common.Hash { - return common.BytesToHash(sender.Bytes()) -} - -func workloadRecipient(cfg config, conflictParticipants int, uniquePrefix string, conflictPrefix string, blockNumber uint64, txIndex int, accountIndex uint64) common.Address { - if cfg.fixedRecipient != nil { - return *cfg.fixedRecipient - } - if txIndex < conflictParticipants { - return blockScopedAddressFromSeed(conflictPrefix, blockNumber, uint64FromNonNegativeInt(txIndex/2)) - } - return addressFromSeed(uniquePrefix, accountIndex) -} - -func recipientConflictParticipants(txsPerBlock int, rate float64) int { - if rate <= 0 || txsPerBlock < 2 { - return 0 - } - if rate >= 1 { - if txsPerBlock%2 == 0 { - return txsPerBlock - } - return txsPerBlock - 1 - } - count := int(math.Round(rate * float64(txsPerBlock))) - if count < 2 { - count = 2 - } - if count > txsPerBlock { - count = txsPerBlock - } - if count%2 != 0 { - if count == txsPerBlock { - count-- - } else { - count++ - } - } - return count -} - -func blockContext(cfg config, number uint64) evmonly.BlockContext { - gasLimit := cfg.blockGasLimit - if gasLimit == 0 { - gasLimit = math.MaxUint64 - } - return evmonly.BlockContext{ - Number: number, - Time: blockTimestamp(number), - GasLimit: gasLimit, - ChainID: new(big.Int).Set(cfg.chainID), - BaseFee: big.NewInt(0), - BlobBaseFee: big.NewInt(0), - Coinbase: cfg.coinbase, - ParentHash: hashFromSeed("sei-evmonly-loadtest-parent", number-1), - BlockHash: hashFromSeed("sei-evmonly-loadtest-block", number), - PrevRandao: hashFromSeed("sei-evmonly-loadtest-randao", number), - } -} - -func blockTimestamp(number uint64) uint64 { - if number > math.MaxUint64-defaultGenesisTimestamp { - return math.MaxUint64 - } - return defaultGenesisTimestamp + number -} - -func deterministicPrivateKey(index uint64) (*ecdsa.PrivateKey, error) { - var buf [16]byte - binary.BigEndian.PutUint64(buf[:8], index) - for attempt := uint64(0); ; attempt++ { - binary.BigEndian.PutUint64(buf[8:], attempt) - key, err := crypto.ToECDSA(crypto.Keccak256([]byte("sei-evmonly-loadtest-sender"), buf[:])) - if err == nil { - return key, nil - } - if attempt == ^uint64(0) { - break - } - } - return nil, fmt.Errorf("could not derive private key for account %d", index) -} - -func addressFromSeed(prefix string, index uint64) common.Address { - hash := hashFromSeed(prefix, index) - return common.BytesToAddress(hash[12:]) -} - -func blockScopedAddressFromSeed(prefix string, blockNumber uint64, index uint64) common.Address { - var buf [16]byte - binary.BigEndian.PutUint64(buf[:8], blockNumber) - binary.BigEndian.PutUint64(buf[8:], index) - hash := crypto.Keccak256Hash([]byte(prefix), buf[:]) - return common.BytesToAddress(hash[12:]) -} - -func hashFromSeed(prefix string, index uint64) common.Hash { - var buf [8]byte - binary.BigEndian.PutUint64(buf[:], index) - return crypto.Keccak256Hash([]byte(prefix), buf[:]) -} - -type generatedState struct { - mu sync.RWMutex - frozen atomic.Bool - balances map[common.Address]*big.Int - nonces map[common.Address]uint64 - code map[common.Address][]byte - storage map[common.Address]map[common.Hash]common.Hash -} - -var _ evmonly.StateReader = (*generatedState)(nil) - -var frozenZeroBalance = new(big.Int) - -func newGeneratedState() *generatedState { - return &generatedState{ - balances: map[common.Address]*big.Int{}, - nonces: map[common.Address]uint64{}, - code: map[common.Address][]byte{}, - storage: map[common.Address]map[common.Hash]common.Hash{}, - } -} - -func (s *generatedState) Freeze() { - s.frozen.Store(true) -} - -func (s *generatedState) GetBalance(addr common.Address) *big.Int { - if s.frozen.Load() { - // Frozen reads return shared, non-owned pointers; StateReader consumers - // must copy before mutation. - if balance, ok := s.balances[addr]; ok && balance != nil { - return balance - } - return frozenZeroBalance - } - s.mu.RLock() - defer s.mu.RUnlock() - if balance, ok := s.balances[addr]; ok && balance != nil { - return new(big.Int).Set(balance) - } - return new(big.Int) -} - -func (s *generatedState) SetBalance(addr common.Address, balance *big.Int) { - s.requireMutable() - s.mu.Lock() - defer s.mu.Unlock() - if balance == nil { - s.balances[addr] = new(big.Int) - return - } - s.balances[addr] = new(big.Int).Set(balance) -} - -func (s *generatedState) GetNonce(addr common.Address) uint64 { - if s.frozen.Load() { - return s.nonces[addr] - } - s.mu.RLock() - defer s.mu.RUnlock() - return s.nonces[addr] -} - -func (s *generatedState) SetNonce(addr common.Address, nonce uint64) { - s.requireMutable() - s.mu.Lock() - defer s.mu.Unlock() - s.nonces[addr] = nonce -} - -func (s *generatedState) GetCode(addr common.Address) []byte { - if s.frozen.Load() { - return cloneBytes(s.code[addr]) - } - s.mu.RLock() - defer s.mu.RUnlock() - return cloneBytes(s.code[addr]) -} - -func (s *generatedState) SetCode(addr common.Address, code []byte) { - s.requireMutable() - s.mu.Lock() - defer s.mu.Unlock() - s.code[addr] = cloneBytes(code) -} - -func (s *generatedState) GetState(addr common.Address, key common.Hash) common.Hash { - if s.frozen.Load() { - if accountStorage, ok := s.storage[addr]; ok { - return accountStorage[key] - } - return common.Hash{} - } - s.mu.RLock() - defer s.mu.RUnlock() - if accountStorage, ok := s.storage[addr]; ok { - return accountStorage[key] - } - return common.Hash{} -} - -func (s *generatedState) SetState(addr common.Address, key common.Hash, value common.Hash) { - s.requireMutable() - s.mu.Lock() - defer s.mu.Unlock() - accountStorage, ok := s.storage[addr] - if !ok { - accountStorage = map[common.Hash]common.Hash{} - s.storage[addr] = accountStorage - } - if value == (common.Hash{}) { - delete(accountStorage, key) - return - } - accountStorage[key] = value -} - -func (s *generatedState) requireMutable() { - if s.frozen.Load() { - panic("generated state is frozen") - } -} - -func cloneBytes(v []byte) []byte { - if len(v) == 0 { - return nil - } - return append([]byte(nil), v...) -} - -type discardStateWriter struct{} - -var _ evmonly.StateWriter = (*discardStateWriter)(nil) - -func (*discardStateWriter) ApplyChangeSet(evmonly.StateChangeSet) {} - -type resultSinks struct { - sink evmonly.ResultSink - close func() error - cleanup func() error -} - -var _ evmonly.ResultSink = (*resultSinks)(nil) - -func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { - switch cfg.resultSink { - case resultSinkDiscard: - return &resultSinks{ - sink: discardResultSink{writer: &discardStateWriter{}}, - }, nil - case resultSinkFile: - return newFileResultSinks(cfg, metrics) - default: - return nil, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) - } -} - -func (s *resultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { - return s.sink.StoreBlockResult(ctx, height, result, release) -} - -func (s *resultSinks) Close() error { - var closeErr error - if s.close == nil { - closeErr = nil - } else { - closeErr = s.close() - } - return errors.Join(closeErr, s.Cleanup()) -} - -func (s *resultSinks) Cleanup() error { - if s.cleanup == nil { - return nil - } - return s.cleanup() -} - -type discardResultSink struct { - writer evmonly.StateWriter -} - -func (s discardResultSink) StoreBlockResult(_ context.Context, _ uint64, result *evmonly.BlockResult, release func()) error { - defer release() - s.writer.ApplyChangeSet(result.ChangeSet) - return nil -} - -type fileResultSinks struct { - changeSetFile *appendRLPFile - receiptFile *appendRLPFile - metrics *loadMetrics - cleanupMu sync.Mutex - paths []string - cleaned map[string]struct{} -} - -func newFileResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { - if err := os.MkdirAll(cfg.persistDir, 0o750); err != nil { - return nil, fmt.Errorf("create persist dir %s: %w", cfg.persistDir, err) - } - changeSetPath := filepath.Join(cfg.persistDir, "changesets.rlp") - receiptPath := filepath.Join(cfg.persistDir, "receipts.rlp") - files := &fileResultSinks{ - metrics: metrics, - paths: []string{changeSetPath, receiptPath}, - cleaned: map[string]struct{}{}, - } - var err error - files.changeSetFile, err = newAppendRLPFile(changeSetPath, cfg.persistBufferSize, cfg.persistSync) - if err != nil { - return nil, err - } - files.receiptFile, err = newAppendRLPFile(receiptPath, cfg.persistBufferSize, cfg.persistSync) - if err != nil { - return nil, errors.Join(err, files.Close()) - } - async := newAsyncFileResultSinks(files, cfg.persistQueueSize, metrics) - return &resultSinks{ - sink: async, - close: async.Close, - cleanup: files.Cleanup, - }, nil -} - -func (s *fileResultSinks) Close() error { - var errs []error - if s.changeSetFile != nil { - errs = append(errs, s.changeSetFile.Close()) - } - if s.receiptFile != nil { - errs = append(errs, s.receiptFile.Close()) - } - errs = append(errs, s.Cleanup()) - return errors.Join(errs...) -} - -func (s *fileResultSinks) Cleanup() error { - s.cleanupMu.Lock() - defer s.cleanupMu.Unlock() - - var errs []error - for _, path := range s.paths { - if _, ok := s.cleaned[path]; ok { - continue - } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - errs = append(errs, fmt.Errorf("remove persist file %s: %w", path, err)) - continue - } - s.cleaned[path] = struct{}{} - } - return errors.Join(errs...) -} - -func (s *fileResultSinks) WriteRecord(kind string, height uint64, value any) error { - var file *appendRLPFile - switch kind { - case resultSinkChangeSet: - file = s.changeSetFile - case resultSinkReceipts: - file = s.receiptFile - default: - return fmt.Errorf("unsupported result sink record kind %q", kind) - } - startedAt := time.Now() - bytes, err := file.WriteRecord(height, value) - elapsed := time.Since(startedAt) - if s.metrics != nil { - s.metrics.recordSinkWrite(kind, bytes, elapsed, err == nil) - } - return err -} - -type resultSinkRecord struct { - height uint64 - result *evmonly.BlockResult - release func() -} - -type asyncFileResultSinks struct { - files *fileResultSinks - metrics *loadMetrics - records chan resultSinkRecord - done chan struct{} - closeErr error - close sync.Once - errMu sync.Mutex - err error -} - -func newAsyncFileResultSinks(files *fileResultSinks, queueSize int, metrics *loadMetrics) *asyncFileResultSinks { - s := &asyncFileResultSinks{ - files: files, - metrics: metrics, - records: make(chan resultSinkRecord, queueSize), - done: make(chan struct{}), - } - if metrics != nil { - metrics.setSinkQueueCapacity(queueSize) - } - go s.run() - return s -} - -func (s *asyncFileResultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { - return s.enqueue(ctx, resultSinkRecord{ - height: height, - result: result, - release: release, - }) -} - -func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRecord) error { - if err := s.getErr(); err != nil { - return err - } - select { - case s.records <- record: - s.recordEnqueued() - return nil - default: - } - - startedAt := time.Now() - select { - case s.records <- record: - if s.metrics != nil { - s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) - } - s.recordEnqueued() - return nil - case <-s.done: - if err := s.getErr(); err != nil { - return err - } - return fmt.Errorf("result sink is closed") - case <-ctx.Done(): - if s.metrics != nil { - s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) - } - return ctx.Err() - } -} - -func (s *asyncFileResultSinks) recordEnqueued() { - if s.metrics == nil { - return - } - s.metrics.recordSinkEnqueued(resultSinkChangeSet) - s.metrics.recordSinkEnqueued(resultSinkReceipts) - s.metrics.setSinkQueued(len(s.records)) -} - -func (s *asyncFileResultSinks) run() { - defer close(s.done) - var writeErr error - for record := range s.records { - if s.metrics != nil { - s.metrics.setSinkQueued(len(s.records)) - } - if writeErr == nil { - if err := s.writeRecord(record); err != nil { - writeErr = err - s.setErr(err) - } - } - record.releaseResult() - if s.metrics != nil { - s.metrics.setSinkQueued(len(s.records)) - } - } - if s.metrics != nil { - s.metrics.setSinkQueued(0) - } -} - -func (s *asyncFileResultSinks) writeRecord(record resultSinkRecord) error { - if err := s.files.WriteRecord(resultSinkChangeSet, record.height, record.result.ChangeSet); err != nil { - return err - } - return s.files.WriteRecord(resultSinkReceipts, record.height, record.result.Receipts) -} - -func (r resultSinkRecord) releaseResult() { - if r.release != nil { - r.release() - } -} - -func (s *asyncFileResultSinks) Close() error { - s.close.Do(func() { - close(s.records) - <-s.done - if s.metrics != nil { - s.metrics.setSinkQueued(0) - } - s.closeErr = errors.Join(s.getErr(), s.files.Close()) - }) - return s.closeErr -} - -func (s *asyncFileResultSinks) setErr(err error) { - s.errMu.Lock() - defer s.errMu.Unlock() - if s.err == nil { - s.err = err - } -} - -func (s *asyncFileResultSinks) getErr() error { - s.errMu.Lock() - defer s.errMu.Unlock() - return s.err -} - -type appendRLPFile struct { - mu sync.Mutex - file *os.File - writer *bufio.Writer - syncOnWrite bool - closed bool -} - -func newAppendRLPFile(path string, bufferSize int, syncOnWrite bool) (*appendRLPFile, error) { - file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // persist output path is an explicit CLI argument. - if err != nil { - return nil, fmt.Errorf("open persist file %s: %w", path, err) - } - return &appendRLPFile{ - file: file, - writer: bufio.NewWriterSize(file, bufferSize), - syncOnWrite: syncOnWrite, - }, nil -} - -func (f *appendRLPFile) WriteRecord(height uint64, value any) (int, error) { - payload, err := rlp.EncodeToBytes(value) - if err != nil { - return 0, fmt.Errorf("encode rlp record for height %d: %w", height, err) - } - var header [16]byte - binary.BigEndian.PutUint64(header[:8], height) - binary.BigEndian.PutUint64(header[8:], uint64(len(payload))) - - f.mu.Lock() - defer f.mu.Unlock() - if f.closed { - return 0, fmt.Errorf("write record for height %d: persist file is closed", height) - } - if _, err := f.writer.Write(header[:]); err != nil { - return 0, fmt.Errorf("write record header for height %d: %w", height, err) - } - if _, err := f.writer.Write(payload); err != nil { - return 0, fmt.Errorf("write record payload for height %d: %w", height, err) - } - if f.syncOnWrite { - if err := f.writer.Flush(); err != nil { - return 0, fmt.Errorf("flush record for height %d: %w", height, err) - } - if err := f.file.Sync(); err != nil { - return 0, fmt.Errorf("sync record for height %d: %w", height, err) - } - } - return len(header) + len(payload), nil -} - -func (f *appendRLPFile) sync() error { - if err := f.writer.Flush(); err != nil { - return err - } - if err := f.file.Sync(); err != nil { - return fmt.Errorf("sync persist file: %w", err) - } - return nil -} - -func (f *appendRLPFile) Close() error { - f.mu.Lock() - defer f.mu.Unlock() - if f.closed { - return nil - } - f.closed = true - return errors.Join(f.sync(), f.file.Close()) -} - -type loadMetrics struct { - inputBlocks atomic.Uint64 - preparedBlocks atomic.Uint64 - preparedTxs atomic.Uint64 - finishedBlocks atomic.Uint64 - finishedTxs atomic.Uint64 - gasConsumed atomic.Uint64 - prepareErrors atomic.Uint64 - executionErrors atomic.Uint64 - occAttempts atomic.Uint64 - occFallbacks atomic.Uint64 - occReruns atomic.Uint64 - occConflicts atomic.Uint64 - sinkEnqueued atomic.Uint64 - sinkWritten atomic.Uint64 - sinkBytes atomic.Uint64 - sinkWaitNanos atomic.Uint64 - sinkWaitEvents atomic.Uint64 - sinkWriteNanos atomic.Uint64 - sinkQueued atomic.Int64 - poolCapacity atomic.Int64 - poolAvailable atomic.Int64 - poolOverflow atomic.Uint64 - - inputBlocksTotal prometheus.Counter - preparedBlocksTotal prometheus.Counter - preparedTxsTotal prometheus.Counter - finishedBlocksTotal prometheus.Counter - finishedTxsTotal prometheus.Counter - gasConsumedTotal prometheus.Counter - prepareErrorsTotal prometheus.Counter - executionErrorsTotal prometheus.Counter - occAttemptsTotal prometheus.Counter - occFallbacksTotal prometheus.Counter - occRerunsTotal prometheus.Counter - occConflictsTotal prometheus.Counter - - occFallbackReasonTotal *prometheus.CounterVec - occConflictAccessTotal *prometheus.CounterVec - sinkEnqueuedTotal *prometheus.CounterVec - sinkWrittenTotal *prometheus.CounterVec - sinkBytesTotal *prometheus.CounterVec - sinkEnqueueWaitTotal prometheus.Counter - sinkEnqueueWaitEvents prometheus.Counter - sinkWriteSecondsTotal *prometheus.CounterVec - - inputBlockRate prometheus.Gauge - preparedBlockRate prometheus.Gauge - preparedTxRate prometheus.Gauge - finishedBlockRate prometheus.Gauge - txRate prometheus.Gauge - gasRate prometheus.Gauge - queuedBlocks prometheus.Gauge - sinkQueuedRecords prometheus.Gauge - sinkQueueCapacity prometheus.Gauge - poolCapacityGauge prometheus.Gauge - poolAvailableGauge prometheus.Gauge - poolOverflowGauge prometheus.Gauge -} - -type metricsSnapshot struct { - at time.Time - inputBlocks uint64 - preparedBlocks uint64 - preparedTxs uint64 - finishedBlocks uint64 - finishedTxs uint64 - gasConsumed uint64 - prepareErrors uint64 - executionErrors uint64 - occAttempts uint64 - occFallbacks uint64 - occReruns uint64 - occConflicts uint64 - sinkEnqueued uint64 - sinkWritten uint64 - sinkBytes uint64 - sinkWaitNanos uint64 - sinkWaitEvents uint64 - sinkWriteNanos uint64 - sinkQueued int64 - poolCapacity int64 - poolAvailable int64 - poolOverflow uint64 -} - -func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { - m := &loadMetrics{ - inputBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_block_input_total", - Help: "Total blocks fed to the EVM-only executor input queue.", - }), - preparedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_block_prepared_total", - Help: "Total blocks decoded and sender-recovered before EVM-only executor execution.", - }), - preparedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_transactions_prepared_total", - Help: "Total transactions decoded and sender-recovered before EVM-only executor execution.", - }), - finishedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_block_finished_total", - Help: "Total blocks that finished EVM-only executor execution.", - }), - finishedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_transactions_finished_total", - Help: "Total transactions that finished EVM-only executor execution.", - }), - gasConsumedTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_gas_consumed_total", - Help: "Total EVM gas consumed by finished blocks.", - }), - prepareErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_prepare_errors_total", - Help: "Total block preparation errors returned while decoding transactions or recovering senders.", - }), - executionErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_execution_errors_total", - Help: "Total block execution errors returned by the EVM-only executor.", - }), - occAttemptsTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_occ_attempts_total", - Help: "Total blocks executed with optimistic concurrency control.", - }), - occFallbacksTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_occ_fallbacks_total", - Help: "Total OCC blocks that fell back to sequential execution.", - }), - occRerunsTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_occ_reruns_total", - Help: "Total OCC transaction rerun attempts caused by stale speculative execution state.", - }), - occConflictsTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_occ_conflicts_total", - Help: "Total OCC conflict accesses observed before sequential fallback.", - }), - occFallbackReasonTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "evmonly_loadtest_occ_fallback_reasons_total", - Help: "OCC fallback count by reason.", - }, []string{"reason"}), - occConflictAccessTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "evmonly_loadtest_occ_conflict_accesses_total", - Help: "OCC conflict accesses by access type and state kind.", - }, []string{"access", "kind"}), - sinkEnqueuedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "evmonly_loadtest_result_sink_records_enqueued_total", - Help: "Total records accepted by the result sink.", - }, []string{"kind"}), - sinkWrittenTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "evmonly_loadtest_result_sink_records_written_total", - Help: "Total records written by the result sink.", - }, []string{"kind"}), - sinkBytesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "evmonly_loadtest_result_sink_bytes_written_total", - Help: "Total bytes written by the result sink, including record framing.", - }, []string{"kind"}), - sinkEnqueueWaitTotal: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_result_sink_enqueue_wait_seconds_total", - Help: "Total time executor workers spent blocked enqueueing result sink records.", - }), - sinkEnqueueWaitEvents: prometheus.NewCounter(prometheus.CounterOpts{ - Name: "evmonly_loadtest_result_sink_enqueue_wait_events_total", - Help: "Total result sink enqueue attempts that had to wait for queue capacity or cancellation.", - }), - sinkWriteSecondsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "evmonly_loadtest_result_sink_write_seconds_total", - Help: "Total time spent by result sink writers serializing, writing, flushing, and optionally syncing records.", - }, []string{"kind"}), - inputBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_block_input_throughput", - Help: "Most recent measured block input throughput in blocks per second.", - }), - preparedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_block_prepared_throughput", - Help: "Most recent measured block preparation throughput in blocks per second.", - }), - preparedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_transactions_prepared_per_second", - Help: "Most recent measured transaction decode and sender recovery throughput in transactions per second.", - }), - finishedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_block_finished_throughput", - Help: "Most recent measured block completion throughput in blocks per second.", - }), - txRate: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_transactions_per_second", - Help: "Most recent measured transaction execution throughput in transactions per second.", - }), - gasRate: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_gas_consumed_per_second", - Help: "Most recent measured gas consumption throughput in gas per second.", - }), - queuedBlocks: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_queued_blocks", - Help: "Blocks currently waiting in the executor input queue.", - }), - sinkQueuedRecords: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_result_sink_queued_records", - Help: "Persistent result sink records currently waiting for the async writer.", - }), - sinkQueueCapacity: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_result_sink_queue_capacity", - Help: "Capacity of the persistent result sink async record queue.", - }), - poolCapacityGauge: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_result_pool_capacity", - Help: "Capacity of the reusable executor block result pool.", - }), - poolAvailableGauge: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_result_pool_available", - Help: "Reusable executor block result slots currently available.", - }), - poolOverflowGauge: prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "evmonly_loadtest_result_pool_overflow_allocations", - Help: "Total block results allocated outside the reusable pool because all slots were retained.", - }), - } - registry.MustRegister( - m.inputBlocksTotal, - m.preparedBlocksTotal, - m.preparedTxsTotal, - m.finishedBlocksTotal, - m.finishedTxsTotal, - m.gasConsumedTotal, - m.prepareErrorsTotal, - m.executionErrorsTotal, - m.occAttemptsTotal, - m.occFallbacksTotal, - m.occRerunsTotal, - m.occConflictsTotal, - m.occFallbackReasonTotal, - m.occConflictAccessTotal, - m.sinkEnqueuedTotal, - m.sinkWrittenTotal, - m.sinkBytesTotal, - m.sinkEnqueueWaitTotal, - m.sinkEnqueueWaitEvents, - m.sinkWriteSecondsTotal, - m.inputBlockRate, - m.preparedBlockRate, - m.preparedTxRate, - m.finishedBlockRate, - m.txRate, - m.gasRate, - m.queuedBlocks, - m.sinkQueuedRecords, - m.sinkQueueCapacity, - m.poolCapacityGauge, - m.poolAvailableGauge, - m.poolOverflowGauge, - ) - return m -} - -func (m *loadMetrics) recordInput() { - m.inputBlocks.Add(1) - m.inputBlocksTotal.Inc() -} - -func (m *loadMetrics) recordPrepared(txCount int) { - m.preparedBlocks.Add(1) - m.preparedTxs.Add(uint64FromNonNegativeInt(txCount)) - m.preparedBlocksTotal.Inc() - m.preparedTxsTotal.Add(float64(txCount)) -} - -func (m *loadMetrics) recordPrepareError() { - m.prepareErrors.Add(1) - m.prepareErrorsTotal.Inc() -} - -func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64, occStats evmonly.OCCStats) { - m.finishedBlocks.Add(1) - m.finishedTxs.Add(uint64FromNonNegativeInt(txCount)) - m.gasConsumed.Add(gasUsed) - m.finishedBlocksTotal.Inc() - m.finishedTxsTotal.Add(float64(txCount)) - m.gasConsumedTotal.Add(float64(gasUsed)) - m.recordOCC(occStats) -} - -func (m *loadMetrics) recordOCC(stats evmonly.OCCStats) { - if !stats.Attempted { - return - } - m.occAttempts.Add(1) - m.occAttemptsTotal.Inc() - if stats.Fallback { - reason := stats.FallbackReason - if reason == "" { - reason = "unknown" - } - m.occFallbacks.Add(1) - m.occFallbacksTotal.Inc() - m.occFallbackReasonTotal.WithLabelValues(reason).Inc() - } - if stats.RerunCount > 0 { - m.occReruns.Add(stats.RerunCount) - m.occRerunsTotal.Add(float64(stats.RerunCount)) - } - if stats.ConflictCount == 0 { - return - } - m.occConflicts.Add(stats.ConflictCount) - m.occConflictsTotal.Add(float64(stats.ConflictCount)) - for _, conflict := range stats.ConflictSamples { - m.occConflictAccessTotal.WithLabelValues( - conflict.Access, - conflict.Kind, - ).Add(float64(conflict.Count)) - } -} - -func (m *loadMetrics) recordExecutionError() { - m.executionErrors.Add(1) - m.executionErrorsTotal.Inc() -} - -func (m *loadMetrics) recordSinkEnqueued(kind string) { - m.sinkEnqueued.Add(1) - m.sinkEnqueuedTotal.WithLabelValues(kind).Inc() -} - -func (m *loadMetrics) recordSinkEnqueueWait(elapsed time.Duration) { - if elapsed <= 0 { - return - } - m.sinkWaitNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) - m.sinkWaitEvents.Add(1) - m.sinkEnqueueWaitTotal.Add(elapsed.Seconds()) - m.sinkEnqueueWaitEvents.Inc() -} - -func (m *loadMetrics) recordSinkWrite(kind string, bytes int, elapsed time.Duration, completed bool) { - if elapsed > 0 { - m.sinkWriteNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) - m.sinkWriteSecondsTotal.WithLabelValues(kind).Add(elapsed.Seconds()) - } - if !completed { - return - } - m.sinkWritten.Add(1) - m.sinkBytes.Add(uint64FromNonNegativeInt(bytes)) - m.sinkWrittenTotal.WithLabelValues(kind).Inc() - m.sinkBytesTotal.WithLabelValues(kind).Add(float64(bytes)) -} - -func (m *loadMetrics) recordResultPoolStats(stats evmonly.BlockResultPoolStats) { - m.poolCapacity.Store(int64(stats.Capacity)) - m.poolAvailable.Store(int64(stats.Available)) - m.poolOverflow.Store(stats.OverflowAllocations) - m.poolCapacityGauge.Set(float64(stats.Capacity)) - m.poolAvailableGauge.Set(float64(stats.Available)) - m.poolOverflowGauge.Set(float64(stats.OverflowAllocations)) -} - -func (m *loadMetrics) setSinkQueued(records int) { - m.sinkQueued.Store(int64(records)) - m.sinkQueuedRecords.Set(float64(records)) -} - -func (m *loadMetrics) setSinkQueueCapacity(records int) { - m.sinkQueueCapacity.Set(float64(records)) -} - -func (m *loadMetrics) snapshot() metricsSnapshot { - return metricsSnapshot{ - at: time.Now(), - inputBlocks: m.inputBlocks.Load(), - preparedBlocks: m.preparedBlocks.Load(), - preparedTxs: m.preparedTxs.Load(), - finishedBlocks: m.finishedBlocks.Load(), - finishedTxs: m.finishedTxs.Load(), - gasConsumed: m.gasConsumed.Load(), - prepareErrors: m.prepareErrors.Load(), - executionErrors: m.executionErrors.Load(), - occAttempts: m.occAttempts.Load(), - occFallbacks: m.occFallbacks.Load(), - occReruns: m.occReruns.Load(), - occConflicts: m.occConflicts.Load(), - sinkEnqueued: m.sinkEnqueued.Load(), - sinkWritten: m.sinkWritten.Load(), - sinkBytes: m.sinkBytes.Load(), - sinkWaitNanos: m.sinkWaitNanos.Load(), - sinkWaitEvents: m.sinkWaitEvents.Load(), - sinkWriteNanos: m.sinkWriteNanos.Load(), - sinkQueued: m.sinkQueued.Load(), - poolCapacity: m.poolCapacity.Load(), - poolAvailable: m.poolAvailable.Load(), - poolOverflow: m.poolOverflow.Load(), - } -} - -func (m *loadMetrics) setRates(prev, curr metricsSnapshot, queued int) { - elapsed := curr.at.Sub(prev.at).Seconds() - if elapsed <= 0 { - return - } - inputRate := float64(curr.inputBlocks-prev.inputBlocks) / elapsed - preparedRate := float64(curr.preparedBlocks-prev.preparedBlocks) / elapsed - preparedTxRate := float64(curr.preparedTxs-prev.preparedTxs) / elapsed - finishedRate := float64(curr.finishedBlocks-prev.finishedBlocks) / elapsed - txRate := float64(curr.finishedTxs-prev.finishedTxs) / elapsed - gasRate := float64(curr.gasConsumed-prev.gasConsumed) / elapsed - m.inputBlockRate.Set(inputRate) - m.preparedBlockRate.Set(preparedRate) - m.preparedTxRate.Set(preparedTxRate) - m.finishedBlockRate.Set(finishedRate) - m.txRate.Set(txRate) - m.gasRate.Set(gasRate) - m.queuedBlocks.Set(float64(queued)) -} - -func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetrics, blocks <-chan preparedBlockEnvelope) { - if interval == 0 { - <-ctx.Done() - return - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - - prev := metrics.snapshot() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - curr := metrics.snapshot() - queued := len(blocks) - metrics.setRates(prev, curr, queued) - elapsed := curr.at.Sub(prev.at).Seconds() - if elapsed <= 0 { - prev = curr - continue - } - sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) - sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) - fmt.Printf( - "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d pool_available=%d pool_overflow=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", - float64(curr.inputBlocks-prev.inputBlocks)/elapsed, - float64(curr.preparedBlocks-prev.preparedBlocks)/elapsed, - float64(curr.preparedTxs-prev.preparedTxs)/elapsed, - float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, - float64(curr.finishedTxs-prev.finishedTxs)/elapsed, - float64(curr.gasConsumed-prev.gasConsumed)/elapsed, - queued, - curr.sinkQueued, - curr.poolAvailable, - curr.poolOverflow, - sinkWaitSeconds/elapsed, - sinkWriteSeconds/elapsed, - curr.inputBlocks, - curr.preparedBlocks, - curr.preparedTxs, - curr.finishedBlocks, - curr.finishedTxs, - curr.gasConsumed, - curr.prepareErrors, - curr.executionErrors, - curr.occAttempts, - curr.occFallbacks, - curr.occReruns, - curr.sinkEnqueued, - curr.sinkWritten, - ) - prev = curr - } - } -} - -func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { - elapsed := snapshot.at.Sub(startedAt).Seconds() - if elapsed <= 0 { - elapsed = 1 - } - fmt.Printf( - "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s pool_capacity=%d pool_available=%d pool_overflow=%d avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", - snapshot.at.Sub(startedAt).Round(time.Millisecond), - snapshot.inputBlocks, - snapshot.preparedBlocks, - snapshot.preparedTxs, - snapshot.finishedBlocks, - snapshot.finishedTxs, - snapshot.gasConsumed, - snapshot.prepareErrors, - snapshot.executionErrors, - snapshot.occAttempts, - snapshot.occFallbacks, - snapshot.occReruns, - snapshot.sinkQueued, - snapshot.sinkEnqueued, - snapshot.sinkWritten, - snapshot.sinkBytes, - durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), - snapshot.sinkWaitEvents, - durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), - snapshot.poolCapacity, - snapshot.poolAvailable, - snapshot.poolOverflow, - float64(snapshot.inputBlocks)/elapsed, - float64(snapshot.preparedBlocks)/elapsed, - float64(snapshot.preparedTxs)/elapsed, - float64(snapshot.finishedBlocks)/elapsed, - float64(snapshot.finishedTxs)/elapsed, - float64(snapshot.gasConsumed)/elapsed, - ) -} - -func printResultSinkReport(closeElapsed time.Duration, snapshot metricsSnapshot) { - fmt.Printf( - "result sink close elapsed=%s sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s\n", - closeElapsed.Round(time.Millisecond), - snapshot.sinkQueued, - snapshot.sinkEnqueued, - snapshot.sinkWritten, - snapshot.sinkBytes, - durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), - snapshot.sinkWaitEvents, - durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), - ) -} - -func printPrebuildReport(elapsed time.Duration, blocks []blockEnvelope, txsPerBlock int) { - seconds := elapsed.Seconds() - if seconds <= 0 { - seconds = 1 - } - txCount := len(blocks) * txsPerBlock - fmt.Printf( - "prebuild complete elapsed=%s blocks=%d txs=%d build_blocks/s=%.2f build_tx/s=%.2f\n", - elapsed.Round(time.Millisecond), - len(blocks), - txCount, - float64(len(blocks))/seconds, - float64(txCount)/seconds, - ) -} - -type metricsServer struct { - server *http.Server - done chan error -} - -func startMetricsServer(addr string, registry *prometheus.Registry) (*metricsServer, error) { - listener, err := net.Listen("tcp", addr) - if err != nil { - return nil, fmt.Errorf("listen for metrics on %s: %w", addr, err) - } - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("ok\n")) - }) - server := &http.Server{ - Addr: addr, - Handler: mux, - ReadHeaderTimeout: 3 * time.Second, - } - ms := &metricsServer{server: server, done: make(chan error, 1)} - go func() { - err := server.Serve(listener) - if errors.Is(err, http.ErrServerClosed) { - err = nil - } - ms.done <- err - }() - return ms, nil -} - -func (s *metricsServer) stop(timeout time.Duration) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - if err := s.server.Shutdown(ctx); err != nil { - return err - } - return <-s.done -} diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index c8205604ee..b5dcf3976c 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" ) func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { @@ -33,8 +34,8 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { require.NoError(t, err) state := newGeneratedState() - workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(t.Context(), 1) + workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ @@ -96,8 +97,8 @@ func TestTransferWorkloadOCCScenarios(t *testing.T) { require.NoError(t, err) state := newGeneratedState() - workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(t.Context(), 1) + workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) signer := ethtypes.LatestSignerForChainID(cfg.chainID) @@ -170,7 +171,7 @@ func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { state := newGeneratedState() workload, err := newWorkload(cfg, state) require.NoError(t, err) - request, err := workload.buildBlock(t.Context(), 1) + request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ @@ -196,14 +197,14 @@ func TestERC20TransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { } applyGeneratedStateChangeSet(state, result.ChangeSet) - transferWorkload := workload.(*erc20TransferWorkload) + transferWorkload := workload.(*scenarios.ERC20TransferWorkload) for i := uint64(1); i <= uint64(cfg.txsPerBlock); i++ { - key, err := deterministicPrivateKey(i) + key, err := scenarios.DeterministicPrivateKey(i) require.NoError(t, err) sender := crypto.PubkeyToAddress(key.PublicKey) - recipient := transferWorkload.recipient(1, int(i-1), i) - require.Equal(t, common.Hash{}, state.GetState(cfg.erc20Contract, erc20BalanceSlot(sender))) - require.Equal(t, common.BigToHash(cfg.transferValue), state.GetState(cfg.erc20Contract, erc20BalanceSlot(recipient))) + recipient := transferWorkload.Recipient(1, int(i-1), i) + require.Equal(t, common.Hash{}, state.GetState(cfg.erc20Contract, scenarios.ERC20BalanceSlot(sender))) + require.Equal(t, common.BigToHash(cfg.transferValue), state.GetState(cfg.erc20Contract, scenarios.ERC20BalanceSlot(recipient))) } } @@ -222,7 +223,7 @@ func TestSnapshotRevertWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { state := newGeneratedState() workload, err := newWorkload(cfg, state) require.NoError(t, err) - request, err := workload.buildBlock(t.Context(), 1) + request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ @@ -251,10 +252,10 @@ func TestSnapshotRevertWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { applyGeneratedStateChangeSet(state, result.ChangeSet) for i := uint64(1); i <= uint64(cfg.txsPerBlock); i++ { - key, err := deterministicPrivateKey(i) + key, err := scenarios.DeterministicPrivateKey(i) require.NoError(t, err) sender := crypto.PubkeyToAddress(key.PublicKey) - slot := snapshotRevertStorageSlot(sender) + slot := scenarios.SnapshotRevertStorageSlot(sender) require.Equal(t, common.BigToHash(big.NewInt(1)), state.GetState(cfg.snapshotRevertContract, slot)) require.Equal(t, common.Hash{}, state.GetState(cfg.snapshotRevertHelper, slot)) } @@ -272,8 +273,8 @@ func TestTransferWorkloadRecipientConflictRate(t *testing.T) { require.NoError(t, err) state := newGeneratedState() - workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(t.Context(), 1) + workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) executor := evmonly.NewExecutor(evmonly.Config{ @@ -287,16 +288,16 @@ func TestTransferWorkloadRecipientConflictRate(t *testing.T) { require.Greater(t, result.OCCStats.ConflictCount, uint64(0)) require.Greater(t, result.OCCStats.RerunCount, uint64(0)) - conflictRecipient := workload.recipient(1, 0, 1) - require.Equal(t, conflictRecipient, workload.recipient(1, 1, 2)) - require.NotEqual(t, conflictRecipient, workload.recipient(1, 2, 3)) - require.NotEqual(t, workload.recipient(1, 2, 3), workload.recipient(1, 3, 4)) + conflictRecipient := workload.Recipient(1, 0, 1) + require.Equal(t, conflictRecipient, workload.Recipient(1, 1, 2)) + require.NotEqual(t, conflictRecipient, workload.Recipient(1, 2, 3)) + require.NotEqual(t, workload.Recipient(1, 2, 3), workload.Recipient(1, 3, 4)) applyGeneratedStateChangeSet(state, result.ChangeSet) twoTransfers := new(big.Int).Mul(cfg.transferValue, big.NewInt(2)) require.Equal(t, twoTransfers, state.GetBalance(conflictRecipient)) - require.Equal(t, cfg.transferValue, state.GetBalance(workload.recipient(1, 2, 3))) - require.Equal(t, cfg.transferValue, state.GetBalance(workload.recipient(1, 3, 4))) + require.Equal(t, cfg.transferValue, state.GetBalance(workload.Recipient(1, 2, 3))) + require.Equal(t, cfg.transferValue, state.GetBalance(workload.Recipient(1, 3, 4))) } func applyGeneratedStateChangeSet(state *generatedState, changeSet evmonly.StateChangeSet) { @@ -515,9 +516,9 @@ func TestBlockContextTimestampIsDeterministic(t *testing.T) { cfg, err := parseConfig([]string{"--metrics-addr=", "--blocks=1"}) require.NoError(t, err) - require.Equal(t, defaultGenesisTimestamp+10, blockContext(cfg, 10).Time) - require.Equal(t, blockContext(cfg, 10).Time, blockContext(cfg, 10).Time) - require.Less(t, blockContext(cfg, 10).Time, blockContext(cfg, 11).Time) + require.Equal(t, defaultGenesisTimestamp+10, scenarios.BlockContext(scenarioConfig(cfg), 10).Time) + require.Equal(t, scenarios.BlockContext(scenarioConfig(cfg), 10).Time, scenarios.BlockContext(scenarioConfig(cfg), 10).Time) + require.Less(t, scenarios.BlockContext(scenarioConfig(cfg), 10).Time, scenarios.BlockContext(scenarioConfig(cfg), 11).Time) } func TestQueueSizeDefaultPersistQueueOverflowValidation(t *testing.T) { @@ -703,7 +704,7 @@ func TestExecutorResultPoolReusesSlotsWithFileSink(t *testing.T) { }) require.NoError(t, err) state := newGeneratedState() - workload := newTransferWorkload(cfg, state) + workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) metrics := newLoadMetrics(prometheus.NewRegistry()) sinks, err := newResultSinks(cfg, metrics) require.NoError(t, err) @@ -714,7 +715,7 @@ func TestExecutorResultPoolReusesSlotsWithFileSink(t *testing.T) { defer executor.Close() for blockNumber := uint64(1); blockNumber <= 20; blockNumber++ { - request, err := workload.buildBlock(t.Context(), blockNumber) + request, err := workload.BuildBlock(t.Context(), blockNumber) require.NoError(t, err) result, err := executor.ExecuteBlock(t.Context(), request) require.NoError(t, err) @@ -831,8 +832,8 @@ func BenchmarkExecuteTransferBlock(b *testing.B) { require.NoError(b, err) state := newGeneratedState() - workload := newTransferWorkload(cfg, state) - request, err := workload.buildBlock(b.Context(), 1) + workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + request, err := workload.BuildBlock(b.Context(), 1) require.NoError(b, err) executor := evmonly.NewExecutor(evmonly.Config{ MinGasPrice: cfg.minGasPrice, diff --git a/giga/evmonly/cmd/evmonly-loadtest/metrics.go b/giga/evmonly/cmd/evmonly-loadtest/metrics.go new file mode 100644 index 0000000000..d1dd69d1a9 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/metrics.go @@ -0,0 +1,593 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync/atomic" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type loadMetrics struct { + inputBlocks atomic.Uint64 + preparedBlocks atomic.Uint64 + preparedTxs atomic.Uint64 + finishedBlocks atomic.Uint64 + finishedTxs atomic.Uint64 + gasConsumed atomic.Uint64 + prepareErrors atomic.Uint64 + executionErrors atomic.Uint64 + occAttempts atomic.Uint64 + occFallbacks atomic.Uint64 + occReruns atomic.Uint64 + occConflicts atomic.Uint64 + sinkEnqueued atomic.Uint64 + sinkWritten atomic.Uint64 + sinkBytes atomic.Uint64 + sinkWaitNanos atomic.Uint64 + sinkWaitEvents atomic.Uint64 + sinkWriteNanos atomic.Uint64 + sinkQueued atomic.Int64 + poolCapacity atomic.Int64 + poolAvailable atomic.Int64 + poolOverflow atomic.Uint64 + + inputBlocksTotal prometheus.Counter + preparedBlocksTotal prometheus.Counter + preparedTxsTotal prometheus.Counter + finishedBlocksTotal prometheus.Counter + finishedTxsTotal prometheus.Counter + gasConsumedTotal prometheus.Counter + prepareErrorsTotal prometheus.Counter + executionErrorsTotal prometheus.Counter + occAttemptsTotal prometheus.Counter + occFallbacksTotal prometheus.Counter + occRerunsTotal prometheus.Counter + occConflictsTotal prometheus.Counter + + occFallbackReasonTotal *prometheus.CounterVec + occConflictAccessTotal *prometheus.CounterVec + sinkEnqueuedTotal *prometheus.CounterVec + sinkWrittenTotal *prometheus.CounterVec + sinkBytesTotal *prometheus.CounterVec + sinkEnqueueWaitTotal prometheus.Counter + sinkEnqueueWaitEvents prometheus.Counter + sinkWriteSecondsTotal *prometheus.CounterVec + + inputBlockRate prometheus.Gauge + preparedBlockRate prometheus.Gauge + preparedTxRate prometheus.Gauge + finishedBlockRate prometheus.Gauge + txRate prometheus.Gauge + gasRate prometheus.Gauge + queuedBlocks prometheus.Gauge + sinkQueuedRecords prometheus.Gauge + sinkQueueCapacity prometheus.Gauge + poolCapacityGauge prometheus.Gauge + poolAvailableGauge prometheus.Gauge + poolOverflowGauge prometheus.Gauge +} + +type metricsSnapshot struct { + at time.Time + inputBlocks uint64 + preparedBlocks uint64 + preparedTxs uint64 + finishedBlocks uint64 + finishedTxs uint64 + gasConsumed uint64 + prepareErrors uint64 + executionErrors uint64 + occAttempts uint64 + occFallbacks uint64 + occReruns uint64 + occConflicts uint64 + sinkEnqueued uint64 + sinkWritten uint64 + sinkBytes uint64 + sinkWaitNanos uint64 + sinkWaitEvents uint64 + sinkWriteNanos uint64 + sinkQueued int64 + poolCapacity int64 + poolAvailable int64 + poolOverflow uint64 +} + +func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { + m := &loadMetrics{ + inputBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_input_total", + Help: "Total blocks fed to the EVM-only executor input queue.", + }), + preparedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_prepared_total", + Help: "Total blocks decoded and sender-recovered before EVM-only executor execution.", + }), + preparedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_prepared_total", + Help: "Total transactions decoded and sender-recovered before EVM-only executor execution.", + }), + finishedBlocksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_block_finished_total", + Help: "Total blocks that finished EVM-only executor execution.", + }), + finishedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_finished_total", + Help: "Total transactions that finished EVM-only executor execution.", + }), + gasConsumedTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_gas_consumed_total", + Help: "Total EVM gas consumed by finished blocks.", + }), + prepareErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_prepare_errors_total", + Help: "Total block preparation errors returned while decoding transactions or recovering senders.", + }), + executionErrorsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_execution_errors_total", + Help: "Total block execution errors returned by the EVM-only executor.", + }), + occAttemptsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_attempts_total", + Help: "Total blocks executed with optimistic concurrency control.", + }), + occFallbacksTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_fallbacks_total", + Help: "Total OCC blocks that fell back to sequential execution.", + }), + occRerunsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_reruns_total", + Help: "Total OCC transaction rerun attempts caused by stale speculative execution state.", + }), + occConflictsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_conflicts_total", + Help: "Total OCC conflict accesses observed before sequential fallback.", + }), + occFallbackReasonTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_fallback_reasons_total", + Help: "OCC fallback count by reason.", + }, []string{"reason"}), + occConflictAccessTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_occ_conflict_accesses_total", + Help: "OCC conflict accesses by access type and state kind.", + }, []string{"access", "kind"}), + sinkEnqueuedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_records_enqueued_total", + Help: "Total records accepted by the result sink.", + }, []string{"kind"}), + sinkWrittenTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_records_written_total", + Help: "Total records written by the result sink.", + }, []string{"kind"}), + sinkBytesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_bytes_written_total", + Help: "Total bytes written by the result sink, including record framing.", + }, []string{"kind"}), + sinkEnqueueWaitTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_enqueue_wait_seconds_total", + Help: "Total time executor workers spent blocked enqueueing result sink records.", + }), + sinkEnqueueWaitEvents: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_enqueue_wait_events_total", + Help: "Total result sink enqueue attempts that had to wait for queue capacity or cancellation.", + }), + sinkWriteSecondsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evmonly_loadtest_result_sink_write_seconds_total", + Help: "Total time spent by result sink writers serializing, writing, flushing, and optionally syncing records.", + }, []string{"kind"}), + inputBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_input_throughput", + Help: "Most recent measured block input throughput in blocks per second.", + }), + preparedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_prepared_throughput", + Help: "Most recent measured block preparation throughput in blocks per second.", + }), + preparedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_prepared_per_second", + Help: "Most recent measured transaction decode and sender recovery throughput in transactions per second.", + }), + finishedBlockRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_block_finished_throughput", + Help: "Most recent measured block completion throughput in blocks per second.", + }), + txRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_per_second", + Help: "Most recent measured transaction execution throughput in transactions per second.", + }), + gasRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_gas_consumed_per_second", + Help: "Most recent measured gas consumption throughput in gas per second.", + }), + queuedBlocks: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_queued_blocks", + Help: "Blocks currently waiting in the executor input queue.", + }), + sinkQueuedRecords: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_sink_queued_records", + Help: "Persistent result sink records currently waiting for the async writer.", + }), + sinkQueueCapacity: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_sink_queue_capacity", + Help: "Capacity of the persistent result sink async record queue.", + }), + poolCapacityGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_capacity", + Help: "Capacity of the reusable executor block result pool.", + }), + poolAvailableGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_available", + Help: "Reusable executor block result slots currently available.", + }), + poolOverflowGauge: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_result_pool_overflow_allocations", + Help: "Total block results allocated outside the reusable pool because all slots were retained.", + }), + } + registry.MustRegister( + m.inputBlocksTotal, + m.preparedBlocksTotal, + m.preparedTxsTotal, + m.finishedBlocksTotal, + m.finishedTxsTotal, + m.gasConsumedTotal, + m.prepareErrorsTotal, + m.executionErrorsTotal, + m.occAttemptsTotal, + m.occFallbacksTotal, + m.occRerunsTotal, + m.occConflictsTotal, + m.occFallbackReasonTotal, + m.occConflictAccessTotal, + m.sinkEnqueuedTotal, + m.sinkWrittenTotal, + m.sinkBytesTotal, + m.sinkEnqueueWaitTotal, + m.sinkEnqueueWaitEvents, + m.sinkWriteSecondsTotal, + m.inputBlockRate, + m.preparedBlockRate, + m.preparedTxRate, + m.finishedBlockRate, + m.txRate, + m.gasRate, + m.queuedBlocks, + m.sinkQueuedRecords, + m.sinkQueueCapacity, + m.poolCapacityGauge, + m.poolAvailableGauge, + m.poolOverflowGauge, + ) + return m +} + +func (m *loadMetrics) recordInput() { + m.inputBlocks.Add(1) + m.inputBlocksTotal.Inc() +} + +func (m *loadMetrics) recordPrepared(txCount int) { + m.preparedBlocks.Add(1) + m.preparedTxs.Add(uint64FromNonNegativeInt(txCount)) + m.preparedBlocksTotal.Inc() + m.preparedTxsTotal.Add(float64(txCount)) +} + +func (m *loadMetrics) recordPrepareError() { + m.prepareErrors.Add(1) + m.prepareErrorsTotal.Inc() +} + +func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64, occStats evmonly.OCCStats) { + m.finishedBlocks.Add(1) + m.finishedTxs.Add(uint64FromNonNegativeInt(txCount)) + m.gasConsumed.Add(gasUsed) + m.finishedBlocksTotal.Inc() + m.finishedTxsTotal.Add(float64(txCount)) + m.gasConsumedTotal.Add(float64(gasUsed)) + m.recordOCC(occStats) +} + +func (m *loadMetrics) recordOCC(stats evmonly.OCCStats) { + if !stats.Attempted { + return + } + m.occAttempts.Add(1) + m.occAttemptsTotal.Inc() + if stats.Fallback { + reason := stats.FallbackReason + if reason == "" { + reason = "unknown" + } + m.occFallbacks.Add(1) + m.occFallbacksTotal.Inc() + m.occFallbackReasonTotal.WithLabelValues(reason).Inc() + } + if stats.RerunCount > 0 { + m.occReruns.Add(stats.RerunCount) + m.occRerunsTotal.Add(float64(stats.RerunCount)) + } + if stats.ConflictCount == 0 { + return + } + m.occConflicts.Add(stats.ConflictCount) + m.occConflictsTotal.Add(float64(stats.ConflictCount)) + for _, conflict := range stats.ConflictSamples { + m.occConflictAccessTotal.WithLabelValues( + conflict.Access, + conflict.Kind, + ).Add(float64(conflict.Count)) + } +} + +func (m *loadMetrics) recordExecutionError() { + m.executionErrors.Add(1) + m.executionErrorsTotal.Inc() +} + +func (m *loadMetrics) recordSinkEnqueued(kind string) { + m.sinkEnqueued.Add(1) + m.sinkEnqueuedTotal.WithLabelValues(kind).Inc() +} + +func (m *loadMetrics) recordSinkEnqueueWait(elapsed time.Duration) { + if elapsed <= 0 { + return + } + m.sinkWaitNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) + m.sinkWaitEvents.Add(1) + m.sinkEnqueueWaitTotal.Add(elapsed.Seconds()) + m.sinkEnqueueWaitEvents.Inc() +} + +func (m *loadMetrics) recordSinkWrite(kind string, bytes int, elapsed time.Duration, completed bool) { + if elapsed > 0 { + m.sinkWriteNanos.Add(uint64FromNonNegativeInt64(elapsed.Nanoseconds())) + m.sinkWriteSecondsTotal.WithLabelValues(kind).Add(elapsed.Seconds()) + } + if !completed { + return + } + m.sinkWritten.Add(1) + m.sinkBytes.Add(uint64FromNonNegativeInt(bytes)) + m.sinkWrittenTotal.WithLabelValues(kind).Inc() + m.sinkBytesTotal.WithLabelValues(kind).Add(float64(bytes)) +} + +func (m *loadMetrics) recordResultPoolStats(stats evmonly.BlockResultPoolStats) { + m.poolCapacity.Store(int64(stats.Capacity)) + m.poolAvailable.Store(int64(stats.Available)) + m.poolOverflow.Store(stats.OverflowAllocations) + m.poolCapacityGauge.Set(float64(stats.Capacity)) + m.poolAvailableGauge.Set(float64(stats.Available)) + m.poolOverflowGauge.Set(float64(stats.OverflowAllocations)) +} + +func (m *loadMetrics) setSinkQueued(records int) { + m.sinkQueued.Store(int64(records)) + m.sinkQueuedRecords.Set(float64(records)) +} + +func (m *loadMetrics) setSinkQueueCapacity(records int) { + m.sinkQueueCapacity.Set(float64(records)) +} + +func (m *loadMetrics) snapshot() metricsSnapshot { + return metricsSnapshot{ + at: time.Now(), + inputBlocks: m.inputBlocks.Load(), + preparedBlocks: m.preparedBlocks.Load(), + preparedTxs: m.preparedTxs.Load(), + finishedBlocks: m.finishedBlocks.Load(), + finishedTxs: m.finishedTxs.Load(), + gasConsumed: m.gasConsumed.Load(), + prepareErrors: m.prepareErrors.Load(), + executionErrors: m.executionErrors.Load(), + occAttempts: m.occAttempts.Load(), + occFallbacks: m.occFallbacks.Load(), + occReruns: m.occReruns.Load(), + occConflicts: m.occConflicts.Load(), + sinkEnqueued: m.sinkEnqueued.Load(), + sinkWritten: m.sinkWritten.Load(), + sinkBytes: m.sinkBytes.Load(), + sinkWaitNanos: m.sinkWaitNanos.Load(), + sinkWaitEvents: m.sinkWaitEvents.Load(), + sinkWriteNanos: m.sinkWriteNanos.Load(), + sinkQueued: m.sinkQueued.Load(), + poolCapacity: m.poolCapacity.Load(), + poolAvailable: m.poolAvailable.Load(), + poolOverflow: m.poolOverflow.Load(), + } +} + +func (m *loadMetrics) setRates(prev, curr metricsSnapshot, queued int) { + elapsed := curr.at.Sub(prev.at).Seconds() + if elapsed <= 0 { + return + } + inputRate := float64(curr.inputBlocks-prev.inputBlocks) / elapsed + preparedRate := float64(curr.preparedBlocks-prev.preparedBlocks) / elapsed + preparedTxRate := float64(curr.preparedTxs-prev.preparedTxs) / elapsed + finishedRate := float64(curr.finishedBlocks-prev.finishedBlocks) / elapsed + txRate := float64(curr.finishedTxs-prev.finishedTxs) / elapsed + gasRate := float64(curr.gasConsumed-prev.gasConsumed) / elapsed + m.inputBlockRate.Set(inputRate) + m.preparedBlockRate.Set(preparedRate) + m.preparedTxRate.Set(preparedTxRate) + m.finishedBlockRate.Set(finishedRate) + m.txRate.Set(txRate) + m.gasRate.Set(gasRate) + m.queuedBlocks.Set(float64(queued)) +} + +func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetrics, blocks <-chan preparedBlockEnvelope) { + if interval == 0 { + <-ctx.Done() + return + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + prev := metrics.snapshot() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + curr := metrics.snapshot() + queued := len(blocks) + metrics.setRates(prev, curr, queued) + elapsed := curr.at.Sub(prev.at).Seconds() + if elapsed <= 0 { + prev = curr + continue + } + sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) + sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) + fmt.Printf( + "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d pool_available=%d pool_overflow=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", + float64(curr.inputBlocks-prev.inputBlocks)/elapsed, + float64(curr.preparedBlocks-prev.preparedBlocks)/elapsed, + float64(curr.preparedTxs-prev.preparedTxs)/elapsed, + float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, + float64(curr.finishedTxs-prev.finishedTxs)/elapsed, + float64(curr.gasConsumed-prev.gasConsumed)/elapsed, + queued, + curr.sinkQueued, + curr.poolAvailable, + curr.poolOverflow, + sinkWaitSeconds/elapsed, + sinkWriteSeconds/elapsed, + curr.inputBlocks, + curr.preparedBlocks, + curr.preparedTxs, + curr.finishedBlocks, + curr.finishedTxs, + curr.gasConsumed, + curr.prepareErrors, + curr.executionErrors, + curr.occAttempts, + curr.occFallbacks, + curr.occReruns, + curr.sinkEnqueued, + curr.sinkWritten, + ) + prev = curr + } + } +} + +func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { + elapsed := snapshot.at.Sub(startedAt).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + fmt.Printf( + "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s pool_capacity=%d pool_available=%d pool_overflow=%d avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + snapshot.at.Sub(startedAt).Round(time.Millisecond), + snapshot.inputBlocks, + snapshot.preparedBlocks, + snapshot.preparedTxs, + snapshot.finishedBlocks, + snapshot.finishedTxs, + snapshot.gasConsumed, + snapshot.prepareErrors, + snapshot.executionErrors, + snapshot.occAttempts, + snapshot.occFallbacks, + snapshot.occReruns, + snapshot.sinkQueued, + snapshot.sinkEnqueued, + snapshot.sinkWritten, + snapshot.sinkBytes, + durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), + snapshot.sinkWaitEvents, + durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), + snapshot.poolCapacity, + snapshot.poolAvailable, + snapshot.poolOverflow, + float64(snapshot.inputBlocks)/elapsed, + float64(snapshot.preparedBlocks)/elapsed, + float64(snapshot.preparedTxs)/elapsed, + float64(snapshot.finishedBlocks)/elapsed, + float64(snapshot.finishedTxs)/elapsed, + float64(snapshot.gasConsumed)/elapsed, + ) +} + +func printResultSinkReport(closeElapsed time.Duration, snapshot metricsSnapshot) { + fmt.Printf( + "result sink close elapsed=%s sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s\n", + closeElapsed.Round(time.Millisecond), + snapshot.sinkQueued, + snapshot.sinkEnqueued, + snapshot.sinkWritten, + snapshot.sinkBytes, + durationFromUint64Nanos(snapshot.sinkWaitNanos).Round(time.Microsecond), + snapshot.sinkWaitEvents, + durationFromUint64Nanos(snapshot.sinkWriteNanos).Round(time.Microsecond), + ) +} + +func printPrebuildReport(elapsed time.Duration, blocks []blockEnvelope, txsPerBlock int) { + seconds := elapsed.Seconds() + if seconds <= 0 { + seconds = 1 + } + txCount := len(blocks) * txsPerBlock + fmt.Printf( + "prebuild complete elapsed=%s blocks=%d txs=%d build_blocks/s=%.2f build_tx/s=%.2f\n", + elapsed.Round(time.Millisecond), + len(blocks), + txCount, + float64(len(blocks))/seconds, + float64(txCount)/seconds, + ) +} + +type metricsServer struct { + server *http.Server + done chan error +} + +func startMetricsServer(addr string, registry *prometheus.Registry) (*metricsServer, error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen for metrics on %s: %w", addr, err) + } + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok\n")) + }) + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 3 * time.Second, + } + ms := &metricsServer{server: server, done: make(chan error, 1)} + go func() { + err := server.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + ms.done <- err + }() + return ms, nil +} + +func (s *metricsServer) stop(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := s.server.Shutdown(ctx); err != nil { + return err + } + return <-s.done +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go new file mode 100644 index 0000000000..9d619cbd1d --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go @@ -0,0 +1,389 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "sync/atomic" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/sei-protocol/sei-chain/giga/evmonly" + "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" + "golang.org/x/sync/errgroup" +) + +func run(cfg config) (err error) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + state := newGeneratedState() + workload, err := newWorkload(cfg, state) + if err != nil { + return err + } + registry := prometheus.NewRegistry() + metrics := newLoadMetrics(registry) + sinks, err := newResultSinks(cfg, metrics) + if err != nil { + return err + } + defer func() { + closeStartedAt := time.Now() + if closeErr := sinks.Close(); closeErr != nil { + if err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink close: %v\n", closeErr) + return + } + err = closeErr + } + if cfg.resultSink == resultSinkFile { + printResultSinkReport(time.Since(closeStartedAt), metrics.snapshot()) + } + }() + stopSinkSignalCleanup := cleanupSinksOnContextCancel(ctx, sinks) + defer stopSinkSignalCleanup() + + var server *metricsServer + if cfg.metricsAddr != "" { + var err error + server, err = startMetricsServer(cfg.metricsAddr, registry) + if err != nil { + return err + } + defer func() { + if err := server.stop(3 * time.Second); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: metrics server shutdown: %v\n", err) + } + }() + fmt.Printf("metrics listening on http://%s/metrics\n", cfg.metricsAddr) + } + + return runPrebuilt(ctx, cfg, state, workload, sinks, metrics) +} + +func cleanupSinksOnContextCancel(ctx context.Context, sinks *resultSinks) func() { + cleanupCtx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + select { + case <-ctx.Done(): + if err := sinks.Cleanup(); err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: result sink cleanup: %v\n", err) + } + case <-cleanupCtx.Done(): + } + }() + return func() { + select { + case <-ctx.Done(): + default: + cancel() + } + <-done + } +} + +type blockWorkload = scenarios.Workload + +func newWorkload(cfg config, state *generatedState) (blockWorkload, error) { + return scenarios.NewWorkload(cfg.workload, scenarioConfig(cfg), state) +} + +func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workload blockWorkload, sinks *resultSinks, metrics *loadMetrics) (err error) { + prebuildStartedAt := time.Now() + prebuilt, err := prebuildBlockRequests(ctx, cfg, workload) + if err != nil { + return err + } + state.Freeze() + prebuildElapsed := time.Since(prebuildStartedAt) + printPrebuildReport(prebuildElapsed, prebuilt, cfg.txsPerBlock) + + profiles, err := startProfiles(cfg) + if err != nil { + return err + } + + blocks := make(chan blockEnvelope, cfg.queueSize) + preparedBlocks := make(chan preparedBlockEnvelope, cfg.queueSize) + reportCtx, stopReporter := context.WithCancel(ctx) + reportDone := make(chan struct{}) + go func() { + defer close(reportDone) + reportLoop(reportCtx, cfg.reportInterval, metrics, preparedBlocks) + }() + + startedAt := time.Now() + group, groupCtx := errgroup.WithContext(ctx) + executor := evmonly.NewExecutor(executorConfig(cfg), evmonly.WithState(state), evmonly.WithResultSink(sinks)) + defer executor.Close() + metrics.recordResultPoolStats(executor.ResultPoolStats()) + group.Go(func() error { + defer close(blocks) + return feedPrebuiltBlocks(groupCtx, prebuilt, blocks, metrics) + }) + group.Go(func() error { + defer close(preparedBlocks) + return prepareBlocks(groupCtx, cfg, executor, blocks, preparedBlocks, metrics) + }) + for workerID := 0; workerID < cfg.workers; workerID++ { + workerID := workerID + group.Go(func() error { + return executeBlocks(groupCtx, workerID, executor, preparedBlocks, metrics) + }) + } + + err = group.Wait() + metrics.recordResultPoolStats(executor.ResultPoolStats()) + stopReporter() + <-reportDone + + if errors.Is(err, context.Canceled) { + err = nil + } + printFinalReport(startedAt, metrics.snapshot()) + // Drop raw block retention before heap profiling forces a GC. + prebuilt = nil + finishProfiles(profiles, &err) + return err +} + +func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorkload) ([]blockEnvelope, error) { + blockCount, err := checkedIntFromUint64("blocks", cfg.blocks) + if err != nil { + return nil, err + } + prebuilt := make([]blockEnvelope, blockCount) + var nextBlock atomic.Uint64 + group, groupCtx := errgroup.WithContext(ctx) + for builderID := 0; builderID < cfg.builders; builderID++ { + group.Go(func() error { + for { + number := nextBlock.Add(1) + if number > cfg.blocks { + return nil + } + request, err := workload.BuildBlock(groupCtx, number) + if err != nil { + if groupCtx.Err() != nil { + return nil + } + return err + } + prebuilt[number-1] = blockEnvelope{ + number: number, + request: request, + } + } + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + return prebuilt, nil +} + +func feedPrebuiltBlocks(ctx context.Context, prebuilt []blockEnvelope, out chan<- blockEnvelope, metrics *loadMetrics) error { + for _, block := range prebuilt { + select { + case out <- block: + metrics.recordInput() + case <-ctx.Done(): + return nil + } + } + return nil +} + +func prepareBlocks( + ctx context.Context, + cfg config, + executor evmonly.PreparedBlockExecutor, + blocks <-chan blockEnvelope, + out chan<- preparedBlockEnvelope, + metrics *loadMetrics, +) error { + unordered := make(chan preparedBlockEnvelope, cfg.queueSize) + workerCtx, cancelWorkers := context.WithCancel(ctx) + defer cancelWorkers() + group, groupCtx := errgroup.WithContext(workerCtx) + for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { + workerID := workerID + group.Go(func() error { + for { + select { + case <-groupCtx.Done(): + return nil + case block, ok := <-blocks: + if !ok { + return nil + } + prepared, err := executor.PrepareBlock(groupCtx, block.request) + if err != nil { + if groupCtx.Err() != nil { + return nil + } + metrics.recordPrepareError() + return fmt.Errorf("prepare worker %d prepare block %d: %w", workerID, block.number, err) + } + select { + case unordered <- preparedBlockEnvelope{number: block.number, block: prepared}: + metrics.recordPrepared(len(prepared.Txs)) + case <-groupCtx.Done(): + return nil + } + } + } + }) + } + done := make(chan error, 1) + go func() { + err := group.Wait() + close(unordered) + done <- err + }() + orderErr := forwardPreparedBlocksInOrder(ctx, unordered, out) + if orderErr != nil { + cancelWorkers() + } + workerErr := <-done + if workerErr != nil { + return errors.Join(workerErr, orderErr) + } + if orderErr != nil { + return orderErr + } + return nil +} + +func forwardPreparedBlocksInOrder(ctx context.Context, in <-chan preparedBlockEnvelope, out chan<- preparedBlockEnvelope) error { + next := uint64(1) + pending := make(map[uint64]preparedBlockEnvelope) + for { + for { + block, ok := pending[next] + if !ok { + break + } + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + delete(pending, next) + next++ + } + + select { + case <-ctx.Done(): + return nil + case block, ok := <-in: + if !ok { + for len(pending) > 0 { + block, ok := pending[next] + if !ok { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("prepared block stream closed before block %d", next) + } + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + delete(pending, next) + next++ + } + return nil + } + if block.number < next { + return fmt.Errorf("prepared block %d arrived after block %d", block.number, next-1) + } + if block.number == next { + if err := sendPreparedBlock(ctx, out, block); err != nil { + return err + } + next++ + continue + } + if _, exists := pending[block.number]; exists { + return fmt.Errorf("duplicate prepared block %d", block.number) + } + pending[block.number] = block + } + } +} + +func sendPreparedBlock(ctx context.Context, out chan<- preparedBlockEnvelope, block preparedBlockEnvelope) error { + select { + case out <- block: + return nil + case <-ctx.Done(): + return nil + } +} + +func checkedIntFromUint64(name string, value uint64) (int, error) { + if value > maxIntAsUint64() { + return 0, fmt.Errorf("%s cannot allocate %d blocks on this platform", name, value) + } + return int(value), nil //nolint:gosec // checked against max int above. +} + +func maxIntAsUint64() uint64 { + return uint64(^uint(0) >> 1) +} + +func uint64FromNonNegativeInt(value int) uint64 { + if value < 0 { + panic("negative integer cannot be converted to uint64") + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func uint64FromNonNegativeInt64(value int64) uint64 { + if value < 0 { + return 0 + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func durationFromUint64Nanos(nanos uint64) time.Duration { + const maxDurationNanos = uint64(1<<63 - 1) + if nanos > maxDurationNanos { + return time.Duration(maxDurationNanos) //nolint:gosec // capped to max int64 duration by construction. + } + return time.Duration(nanos) //nolint:gosec // capped to max int64 duration above. +} + +func executeBlocks( + ctx context.Context, + workerID int, + executor *evmonly.Executor, + blocks <-chan preparedBlockEnvelope, + metrics *loadMetrics, +) error { + for { + select { + case <-ctx.Done(): + return nil + case block, ok := <-blocks: + if !ok { + return nil + } + result, err := executor.ExecutePreparedBlock(ctx, block.block) + if err != nil { + if ctx.Err() != nil { + return nil + } + metrics.recordExecutionError() + return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) + } + metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) + result.Release() + metrics.recordResultPoolStats(executor.ResultPoolStats()) + } + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/profiles.go b/giga/evmonly/cmd/evmonly-loadtest/profiles.go new file mode 100644 index 0000000000..ac1fca6bb2 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/profiles.go @@ -0,0 +1,117 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/pprof" + "runtime/trace" +) + +type profileSession struct { + cpuFile *os.File + traceFile *os.File + heapPath string + cpuActive bool + traceActive bool + heapComplete bool +} + +func startProfiles(cfg config) (*profileSession, error) { + if cfg.cpuProfile == "" && cfg.heapProfile == "" && cfg.traceProfile == "" { + return nil, nil + } + session := &profileSession{heapPath: cfg.heapProfile} + if cfg.cpuProfile != "" { + file, err := createProfileFile(cfg.cpuProfile) + if err != nil { + return nil, err + } + session.cpuFile = file + if err := pprof.StartCPUProfile(file); err != nil { + _ = session.Close() + return nil, fmt.Errorf("start CPU profile: %w", err) + } + session.cpuActive = true + } + if cfg.traceProfile != "" { + file, err := createProfileFile(cfg.traceProfile) + if err != nil { + _ = session.Close() + return nil, err + } + session.traceFile = file + if err := trace.Start(file); err != nil { + _ = session.Close() + return nil, fmt.Errorf("start runtime trace: %w", err) + } + session.traceActive = true + } + return session, nil +} + +func createProfileFile(path string) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create profile dir for %s: %w", path, err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // profile output path is an explicit CLI argument. + if err != nil { + return nil, fmt.Errorf("create profile file %s: %w", path, err) + } + return file, nil +} + +func (p *profileSession) Close() error { + if p == nil { + return nil + } + var errs []error + if p.traceActive { + trace.Stop() + p.traceActive = false + } + if p.cpuActive { + pprof.StopCPUProfile() + p.cpuActive = false + } + if p.cpuFile != nil { + errs = append(errs, p.cpuFile.Close()) + p.cpuFile = nil + } + if p.traceFile != nil { + errs = append(errs, p.traceFile.Close()) + p.traceFile = nil + } + if p.heapPath != "" && !p.heapComplete { + errs = append(errs, writeHeapProfile(p.heapPath)) + p.heapComplete = true + } + return errors.Join(errs...) +} + +func writeHeapProfile(path string) (err error) { + file, err := createProfileFile(path) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, file.Close()) + }() + runtime.GC() + if err := pprof.WriteHeapProfile(file); err != nil { + return fmt.Errorf("write heap profile %s: %w", path, err) + } + return nil +} + +func finishProfiles(session *profileSession, err *error) { + if closeErr := session.Close(); closeErr != nil { + if *err != nil { + fmt.Fprintf(os.Stderr, "evmonly-loadtest: write profile: %v\n", closeErr) + return + } + *err = closeErr + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go new file mode 100644 index 0000000000..7b3ca847a9 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go @@ -0,0 +1,102 @@ +package scenarios + +import ( + "context" + "math/big" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type ERC20TransferWorkload struct { + cfg Config + state State + signer ethtypes.Signer + conflictParticipants int + accountCursor atomic.Uint64 +} + +var ( + erc20TransferSelector = [4]byte{0xa9, 0x05, 0x9c, 0xbb} + // Minimal ERC20-like runtime for transfer(address,uint256), with balances at + // storage slot 0 and a standard Transfer(address,address,uint256) log. + erc20TransferRuntimeCode = common.FromHex("0x60003560e01c63a9059cbb1460145760006000fd5b60243560043533600052600060205260406000208054831060805780548303905580600052600060205260406000208054830190558160005280337fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60206000a3600160005260206000f35b60006000fd") +) + +func NewERC20TransferWorkload(cfg Config, state State) *ERC20TransferWorkload { + state.SetCode(cfg.ERC20Contract, erc20TransferRuntimeCode) + return &ERC20TransferWorkload{ + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.ChainID), + conflictParticipants: recipientConflictParticipants(cfg.TxsPerBlock, cfg.RecipientConflictRate), + } +} + +func (w *ERC20TransferWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.TxsPerBlock) + for i := 0; i < w.cfg.TxsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + recipient := w.Recipient(number, i, accountIndex) + raw, sender, err := w.buildTransferTx(accountIndex, recipient) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.state.SetBalance(sender, w.cfg.SenderBalance) + w.state.SetState(w.cfg.ERC20Contract, ERC20BalanceSlot(sender), common.BigToHash(w.cfg.TransferValue)) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: BlockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *ERC20TransferWorkload) buildTransferTx(accountIndex uint64, recipient common.Address) ([]byte, common.Address, error) { + key, err := DeterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: new(big.Int).Set(w.cfg.GasPrice), + Gas: w.cfg.TxGasLimit, + To: &w.cfg.ERC20Contract, + Value: new(big.Int), + Data: erc20TransferCalldata(recipient, w.cfg.TransferValue), + }) + signed, err := ethtypes.SignTx(tx, w.signer, key) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} + +func (w *ERC20TransferWorkload) Recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-erc20-recipient", "sei-evmonly-loadtest-erc20-conflict-recipient", blockNumber, txIndex, accountIndex) +} +func erc20TransferCalldata(recipient common.Address, amount *big.Int) []byte { + data := make([]byte, 4+32+32) + copy(data[:4], erc20TransferSelector[:]) + copy(data[4+12:36], recipient.Bytes()) + amount.FillBytes(data[36:68]) + return data +} +func ERC20BalanceSlot(owner common.Address) common.Hash { + var encoded [64]byte + copy(encoded[12:32], owner.Bytes()) + return crypto.Keccak256Hash(encoded[:]) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/helpers.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/helpers.go new file mode 100644 index 0000000000..d027eb273c --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/helpers.go @@ -0,0 +1,118 @@ +package scenarios + +import ( + "crypto/ecdsa" + "encoding/binary" + "fmt" + "math" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +func workloadRecipient(cfg Config, conflictParticipants int, uniquePrefix string, conflictPrefix string, blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + if cfg.FixedRecipient != nil { + return *cfg.FixedRecipient + } + if txIndex < conflictParticipants { + return blockScopedAddressFromSeed(conflictPrefix, blockNumber, uint64FromNonNegativeInt(txIndex/2)) + } + return addressFromSeed(uniquePrefix, accountIndex) +} + +func recipientConflictParticipants(txsPerBlock int, rate float64) int { + if rate <= 0 || txsPerBlock < 2 { + return 0 + } + if rate >= 1 { + if txsPerBlock%2 == 0 { + return txsPerBlock + } + return txsPerBlock - 1 + } + count := int(math.Round(rate * float64(txsPerBlock))) + if count < 2 { + count = 2 + } + if count > txsPerBlock { + count = txsPerBlock + } + if count%2 != 0 { + if count == txsPerBlock { + count-- + } else { + count++ + } + } + return count +} + +func uint64FromNonNegativeInt(value int) uint64 { + if value < 0 { + panic("negative integer cannot be converted to uint64") + } + return uint64(value) //nolint:gosec // negative values are rejected above. +} + +func BlockContext(cfg Config, number uint64) evmonly.BlockContext { + gasLimit := cfg.BlockGasLimit + if gasLimit == 0 { + gasLimit = math.MaxUint64 + } + return evmonly.BlockContext{ + Number: number, + Time: blockTimestamp(number), + GasLimit: gasLimit, + ChainID: new(big.Int).Set(cfg.ChainID), + BaseFee: big.NewInt(0), + BlobBaseFee: big.NewInt(0), + Coinbase: cfg.Coinbase, + ParentHash: hashFromSeed("sei-evmonly-loadtest-parent", number-1), + BlockHash: hashFromSeed("sei-evmonly-loadtest-block", number), + PrevRandao: hashFromSeed("sei-evmonly-loadtest-randao", number), + } +} + +func blockTimestamp(number uint64) uint64 { + if number > math.MaxUint64-DefaultGenesisTimestamp { + return math.MaxUint64 + } + return DefaultGenesisTimestamp + number +} + +func DeterministicPrivateKey(index uint64) (*ecdsa.PrivateKey, error) { + var buf [16]byte + binary.BigEndian.PutUint64(buf[:8], index) + for attempt := uint64(0); ; attempt++ { + binary.BigEndian.PutUint64(buf[8:], attempt) + key, err := crypto.ToECDSA(crypto.Keccak256([]byte("sei-evmonly-loadtest-sender"), buf[:])) + if err == nil { + return key, nil + } + if attempt == ^uint64(0) { + break + } + } + return nil, fmt.Errorf("could not derive private key for account %d", index) +} + +func addressFromSeed(prefix string, index uint64) common.Address { + hash := hashFromSeed(prefix, index) + return common.BytesToAddress(hash[12:]) +} + +func blockScopedAddressFromSeed(prefix string, blockNumber uint64, index uint64) common.Address { + var buf [16]byte + binary.BigEndian.PutUint64(buf[:8], blockNumber) + binary.BigEndian.PutUint64(buf[8:], index) + hash := crypto.Keccak256Hash([]byte(prefix), buf[:]) + return common.BytesToAddress(hash[12:]) +} + +func hashFromSeed(prefix string, index uint64) common.Hash { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], index) + return crypto.Keccak256Hash([]byte(prefix), buf[:]) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/snapshot_revert.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/snapshot_revert.go new file mode 100644 index 0000000000..8c3f87c91c --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/snapshot_revert.go @@ -0,0 +1,94 @@ +package scenarios + +import ( + "context" + "math/big" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type SnapshotRevertWorkload struct { + cfg Config + state State + signer ethtypes.Signer + accountCursor atomic.Uint64 +} + +var ( + // The outer runtime stores 1 at storage[CALLER], delegatecalls the helper + // address encoded in calldata, ignores the helper's reverted status, and + // returns successfully. + snapshotRevertOuterRuntimeCode = common.FromHex("0x6001335560006000600060006000355af450600160005260206000f3") + // The helper runtime runs under DELEGATECALL, overwrites storage[CALLER] + // with 2 in the outer storage context, then executes REVERT. + snapshotRevertHelperRuntimeCode = common.FromHex("0x6002335560006000fd") +) + +func NewSnapshotRevertWorkload(cfg Config, state State) *SnapshotRevertWorkload { + state.SetCode(cfg.SnapshotRevertContract, snapshotRevertOuterRuntimeCode) + state.SetCode(cfg.SnapshotRevertHelper, snapshotRevertHelperRuntimeCode) + return &SnapshotRevertWorkload{ + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.ChainID), + } +} + +func (w *SnapshotRevertWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.TxsPerBlock) + for i := 0; i < w.cfg.TxsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + raw, sender, err := w.buildCallTx(accountIndex) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.state.SetBalance(sender, w.cfg.SenderBalance) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: BlockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *SnapshotRevertWorkload) buildCallTx(accountIndex uint64) ([]byte, common.Address, error) { + key, err := DeterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: new(big.Int).Set(w.cfg.GasPrice), + Gas: w.cfg.TxGasLimit, + To: &w.cfg.SnapshotRevertContract, + Value: new(big.Int), + Data: addressCalldata(w.cfg.SnapshotRevertHelper), + }) + signed, err := ethtypes.SignTx(tx, w.signer, key) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} +func addressCalldata(addr common.Address) []byte { + data := make([]byte, 32) + copy(data[12:], addr.Bytes()) + return data +} +func SnapshotRevertStorageSlot(sender common.Address) common.Hash { + return common.BytesToHash(sender.Bytes()) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go new file mode 100644 index 0000000000..6fb985920d --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go @@ -0,0 +1,86 @@ +package scenarios + +import ( + "context" + "math/big" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type TransferWorkload struct { + cfg Config + state State + signer ethtypes.Signer + conflictParticipants int + accountCursor atomic.Uint64 +} + +func NewTransferWorkload(cfg Config, state State) *TransferWorkload { + return &TransferWorkload{ + cfg: cfg, + state: state, + signer: ethtypes.LatestSignerForChainID(cfg.ChainID), + conflictParticipants: recipientConflictParticipants(cfg.TxsPerBlock, cfg.RecipientConflictRate), + } +} + +func (w *TransferWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { + txs := make([][]byte, w.cfg.TxsPerBlock) + for i := 0; i < w.cfg.TxsPerBlock; i++ { + select { + case <-ctx.Done(): + return evmonly.BlockRequest{}, ctx.Err() + default: + } + accountIndex := w.accountCursor.Add(1) + senderIndex := accountIndex + nonce := uint64(0) + if w.cfg.SameSender { + senderIndex = number + nonce = uint64(i) //nolint:gosec // i is bounded by txsPerBlock. + } + recipient := w.Recipient(number, i, accountIndex) + raw, sender, err := w.buildTransferTx(senderIndex, nonce, recipient) + if err != nil { + return evmonly.BlockRequest{}, err + } + w.state.SetBalance(sender, w.cfg.SenderBalance) + txs[i] = raw + } + return evmonly.BlockRequest{ + Context: BlockContext(w.cfg, number), + Txs: txs, + }, nil +} + +func (w *TransferWorkload) buildTransferTx(accountIndex, nonce uint64, recipient common.Address) ([]byte, common.Address, error) { + key, err := DeterministicPrivateKey(accountIndex) + if err != nil { + return nil, common.Address{}, err + } + sender := crypto.PubkeyToAddress(key.PublicKey) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: nonce, + GasPrice: new(big.Int).Set(w.cfg.GasPrice), + Gas: w.cfg.TxGasLimit, + To: &recipient, + Value: new(big.Int).Set(w.cfg.TransferValue), + }) + signed, err := ethtypes.SignTx(tx, w.signer, key) + if err != nil { + return nil, common.Address{}, err + } + raw, err := signed.MarshalBinary() + if err != nil { + return nil, common.Address{}, err + } + return raw, sender, nil +} + +func (w *TransferWorkload) Recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { + return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-recipient", "sei-evmonly-loadtest-conflict-recipient", blockNumber, txIndex, accountIndex) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go new file mode 100644 index 0000000000..4a513aaacb --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go @@ -0,0 +1,58 @@ +package scenarios + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +const ( + WorkloadTransfer = "transfer" + WorkloadERC20Transfer = "erc20-transfer" + WorkloadSnapshotRevert = "snapshot-revert" + + DefaultGenesisTimestamp = uint64(1_700_000_000) +) + +type Config struct { + TxsPerBlock int + ChainID *big.Int + GasPrice *big.Int + SenderBalance *big.Int + TransferValue *big.Int + TxGasLimit uint64 + BlockGasLimit uint64 + Coinbase common.Address + ERC20Contract common.Address + SnapshotRevertContract common.Address + SnapshotRevertHelper common.Address + FixedRecipient *common.Address + RecipientConflictRate float64 + SameSender bool +} + +type State interface { + SetBalance(common.Address, *big.Int) + SetCode(common.Address, []byte) + SetState(common.Address, common.Hash, common.Hash) +} + +type Workload interface { + BuildBlock(context.Context, uint64) (evmonly.BlockRequest, error) +} + +func NewWorkload(kind string, cfg Config, state State) (Workload, error) { + switch kind { + case WorkloadTransfer: + return NewTransferWorkload(cfg, state), nil + case WorkloadERC20Transfer: + return NewERC20TransferWorkload(cfg, state), nil + case WorkloadSnapshotRevert: + return NewSnapshotRevertWorkload(cfg, state), nil + default: + return nil, fmt.Errorf("unsupported workload %q", kind) + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/sinks.go b/giga/evmonly/cmd/evmonly-loadtest/sinks.go new file mode 100644 index 0000000000..a26f123a96 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/sinks.go @@ -0,0 +1,373 @@ +package main + +import ( + "bufio" + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type discardStateWriter struct{} + +var _ evmonly.StateWriter = (*discardStateWriter)(nil) + +func (*discardStateWriter) ApplyChangeSet(evmonly.StateChangeSet) {} + +type resultSinks struct { + sink evmonly.ResultSink + close func() error + cleanup func() error +} + +var _ evmonly.ResultSink = (*resultSinks)(nil) + +func newResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { + switch cfg.resultSink { + case resultSinkDiscard: + return &resultSinks{ + sink: discardResultSink{writer: &discardStateWriter{}}, + }, nil + case resultSinkFile: + return newFileResultSinks(cfg, metrics) + default: + return nil, fmt.Errorf("unsupported result-sink %q", cfg.resultSink) + } +} + +func (s *resultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { + return s.sink.StoreBlockResult(ctx, height, result, release) +} + +func (s *resultSinks) Close() error { + var closeErr error + if s.close == nil { + closeErr = nil + } else { + closeErr = s.close() + } + return errors.Join(closeErr, s.Cleanup()) +} + +func (s *resultSinks) Cleanup() error { + if s.cleanup == nil { + return nil + } + return s.cleanup() +} + +type discardResultSink struct { + writer evmonly.StateWriter +} + +func (s discardResultSink) StoreBlockResult(_ context.Context, _ uint64, result *evmonly.BlockResult, release func()) error { + defer release() + s.writer.ApplyChangeSet(result.ChangeSet) + return nil +} + +type fileResultSinks struct { + changeSetFile *appendRLPFile + receiptFile *appendRLPFile + metrics *loadMetrics + cleanupMu sync.Mutex + paths []string + cleaned map[string]struct{} +} + +func newFileResultSinks(cfg config, metrics *loadMetrics) (*resultSinks, error) { + if err := os.MkdirAll(cfg.persistDir, 0o750); err != nil { + return nil, fmt.Errorf("create persist dir %s: %w", cfg.persistDir, err) + } + changeSetPath := filepath.Join(cfg.persistDir, "changesets.rlp") + receiptPath := filepath.Join(cfg.persistDir, "receipts.rlp") + files := &fileResultSinks{ + metrics: metrics, + paths: []string{changeSetPath, receiptPath}, + cleaned: map[string]struct{}{}, + } + var err error + files.changeSetFile, err = newAppendRLPFile(changeSetPath, cfg.persistBufferSize, cfg.persistSync) + if err != nil { + return nil, err + } + files.receiptFile, err = newAppendRLPFile(receiptPath, cfg.persistBufferSize, cfg.persistSync) + if err != nil { + return nil, errors.Join(err, files.Close()) + } + async := newAsyncFileResultSinks(files, cfg.persistQueueSize, metrics) + return &resultSinks{ + sink: async, + close: async.Close, + cleanup: files.Cleanup, + }, nil +} + +func (s *fileResultSinks) Close() error { + var errs []error + if s.changeSetFile != nil { + errs = append(errs, s.changeSetFile.Close()) + } + if s.receiptFile != nil { + errs = append(errs, s.receiptFile.Close()) + } + errs = append(errs, s.Cleanup()) + return errors.Join(errs...) +} + +func (s *fileResultSinks) Cleanup() error { + s.cleanupMu.Lock() + defer s.cleanupMu.Unlock() + + var errs []error + for _, path := range s.paths { + if _, ok := s.cleaned[path]; ok { + continue + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("remove persist file %s: %w", path, err)) + continue + } + s.cleaned[path] = struct{}{} + } + return errors.Join(errs...) +} + +func (s *fileResultSinks) WriteRecord(kind string, height uint64, value any) error { + var file *appendRLPFile + switch kind { + case resultSinkChangeSet: + file = s.changeSetFile + case resultSinkReceipts: + file = s.receiptFile + default: + return fmt.Errorf("unsupported result sink record kind %q", kind) + } + startedAt := time.Now() + bytes, err := file.WriteRecord(height, value) + elapsed := time.Since(startedAt) + if s.metrics != nil { + s.metrics.recordSinkWrite(kind, bytes, elapsed, err == nil) + } + return err +} + +type resultSinkRecord struct { + height uint64 + result *evmonly.BlockResult + release func() +} + +type asyncFileResultSinks struct { + files *fileResultSinks + metrics *loadMetrics + records chan resultSinkRecord + done chan struct{} + closeErr error + close sync.Once + errMu sync.Mutex + err error +} + +func newAsyncFileResultSinks(files *fileResultSinks, queueSize int, metrics *loadMetrics) *asyncFileResultSinks { + s := &asyncFileResultSinks{ + files: files, + metrics: metrics, + records: make(chan resultSinkRecord, queueSize), + done: make(chan struct{}), + } + if metrics != nil { + metrics.setSinkQueueCapacity(queueSize) + } + go s.run() + return s +} + +func (s *asyncFileResultSinks) StoreBlockResult(ctx context.Context, height uint64, result *evmonly.BlockResult, release func()) error { + return s.enqueue(ctx, resultSinkRecord{ + height: height, + result: result, + release: release, + }) +} + +func (s *asyncFileResultSinks) enqueue(ctx context.Context, record resultSinkRecord) error { + if err := s.getErr(); err != nil { + return err + } + select { + case s.records <- record: + s.recordEnqueued() + return nil + default: + } + + startedAt := time.Now() + select { + case s.records <- record: + if s.metrics != nil { + s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) + } + s.recordEnqueued() + return nil + case <-s.done: + if err := s.getErr(); err != nil { + return err + } + return fmt.Errorf("result sink is closed") + case <-ctx.Done(): + if s.metrics != nil { + s.metrics.recordSinkEnqueueWait(time.Since(startedAt)) + } + return ctx.Err() + } +} + +func (s *asyncFileResultSinks) recordEnqueued() { + if s.metrics == nil { + return + } + s.metrics.recordSinkEnqueued(resultSinkChangeSet) + s.metrics.recordSinkEnqueued(resultSinkReceipts) + s.metrics.setSinkQueued(len(s.records)) +} + +func (s *asyncFileResultSinks) run() { + defer close(s.done) + var writeErr error + for record := range s.records { + if s.metrics != nil { + s.metrics.setSinkQueued(len(s.records)) + } + if writeErr == nil { + if err := s.writeRecord(record); err != nil { + writeErr = err + s.setErr(err) + } + } + record.releaseResult() + if s.metrics != nil { + s.metrics.setSinkQueued(len(s.records)) + } + } + if s.metrics != nil { + s.metrics.setSinkQueued(0) + } +} + +func (s *asyncFileResultSinks) writeRecord(record resultSinkRecord) error { + if err := s.files.WriteRecord(resultSinkChangeSet, record.height, record.result.ChangeSet); err != nil { + return err + } + return s.files.WriteRecord(resultSinkReceipts, record.height, record.result.Receipts) +} + +func (r resultSinkRecord) releaseResult() { + if r.release != nil { + r.release() + } +} + +func (s *asyncFileResultSinks) Close() error { + s.close.Do(func() { + close(s.records) + <-s.done + if s.metrics != nil { + s.metrics.setSinkQueued(0) + } + s.closeErr = errors.Join(s.getErr(), s.files.Close()) + }) + return s.closeErr +} + +func (s *asyncFileResultSinks) setErr(err error) { + s.errMu.Lock() + defer s.errMu.Unlock() + if s.err == nil { + s.err = err + } +} + +func (s *asyncFileResultSinks) getErr() error { + s.errMu.Lock() + defer s.errMu.Unlock() + return s.err +} + +type appendRLPFile struct { + mu sync.Mutex + file *os.File + writer *bufio.Writer + syncOnWrite bool + closed bool +} + +func newAppendRLPFile(path string, bufferSize int, syncOnWrite bool) (*appendRLPFile, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) //nolint:gosec // persist output path is an explicit CLI argument. + if err != nil { + return nil, fmt.Errorf("open persist file %s: %w", path, err) + } + return &appendRLPFile{ + file: file, + writer: bufio.NewWriterSize(file, bufferSize), + syncOnWrite: syncOnWrite, + }, nil +} + +func (f *appendRLPFile) WriteRecord(height uint64, value any) (int, error) { + payload, err := rlp.EncodeToBytes(value) + if err != nil { + return 0, fmt.Errorf("encode rlp record for height %d: %w", height, err) + } + var header [16]byte + binary.BigEndian.PutUint64(header[:8], height) + binary.BigEndian.PutUint64(header[8:], uint64(len(payload))) + + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return 0, fmt.Errorf("write record for height %d: persist file is closed", height) + } + if _, err := f.writer.Write(header[:]); err != nil { + return 0, fmt.Errorf("write record header for height %d: %w", height, err) + } + if _, err := f.writer.Write(payload); err != nil { + return 0, fmt.Errorf("write record payload for height %d: %w", height, err) + } + if f.syncOnWrite { + if err := f.writer.Flush(); err != nil { + return 0, fmt.Errorf("flush record for height %d: %w", height, err) + } + if err := f.file.Sync(); err != nil { + return 0, fmt.Errorf("sync record for height %d: %w", height, err) + } + } + return len(header) + len(payload), nil +} + +func (f *appendRLPFile) sync() error { + if err := f.writer.Flush(); err != nil { + return err + } + if err := f.file.Sync(); err != nil { + return fmt.Errorf("sync persist file: %w", err) + } + return nil +} + +func (f *appendRLPFile) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return nil + } + f.closed = true + return errors.Join(f.sync(), f.file.Close()) +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/state.go b/giga/evmonly/cmd/evmonly-loadtest/state.go new file mode 100644 index 0000000000..95b8b3c322 --- /dev/null +++ b/giga/evmonly/cmd/evmonly-loadtest/state.go @@ -0,0 +1,140 @@ +package main + +import ( + "math/big" + "sync" + "sync/atomic" + + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +type generatedState struct { + mu sync.RWMutex + frozen atomic.Bool + balances map[common.Address]*big.Int + nonces map[common.Address]uint64 + code map[common.Address][]byte + storage map[common.Address]map[common.Hash]common.Hash +} + +var _ evmonly.StateReader = (*generatedState)(nil) + +var frozenZeroBalance = new(big.Int) + +func newGeneratedState() *generatedState { + return &generatedState{ + balances: map[common.Address]*big.Int{}, + nonces: map[common.Address]uint64{}, + code: map[common.Address][]byte{}, + storage: map[common.Address]map[common.Hash]common.Hash{}, + } +} + +func (s *generatedState) Freeze() { + s.frozen.Store(true) +} + +func (s *generatedState) GetBalance(addr common.Address) *big.Int { + if s.frozen.Load() { + // Frozen reads return shared, non-owned pointers; StateReader consumers + // must copy before mutation. + if balance, ok := s.balances[addr]; ok && balance != nil { + return balance + } + return frozenZeroBalance + } + s.mu.RLock() + defer s.mu.RUnlock() + if balance, ok := s.balances[addr]; ok && balance != nil { + return new(big.Int).Set(balance) + } + return new(big.Int) +} + +func (s *generatedState) SetBalance(addr common.Address, balance *big.Int) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + if balance == nil { + s.balances[addr] = new(big.Int) + return + } + s.balances[addr] = new(big.Int).Set(balance) +} + +func (s *generatedState) GetNonce(addr common.Address) uint64 { + if s.frozen.Load() { + return s.nonces[addr] + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.nonces[addr] +} + +func (s *generatedState) SetNonce(addr common.Address, nonce uint64) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + s.nonces[addr] = nonce +} + +func (s *generatedState) GetCode(addr common.Address) []byte { + if s.frozen.Load() { + return cloneBytes(s.code[addr]) + } + s.mu.RLock() + defer s.mu.RUnlock() + return cloneBytes(s.code[addr]) +} + +func (s *generatedState) SetCode(addr common.Address, code []byte) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + s.code[addr] = cloneBytes(code) +} + +func (s *generatedState) GetState(addr common.Address, key common.Hash) common.Hash { + if s.frozen.Load() { + if accountStorage, ok := s.storage[addr]; ok { + return accountStorage[key] + } + return common.Hash{} + } + s.mu.RLock() + defer s.mu.RUnlock() + if accountStorage, ok := s.storage[addr]; ok { + return accountStorage[key] + } + return common.Hash{} +} + +func (s *generatedState) SetState(addr common.Address, key common.Hash, value common.Hash) { + s.requireMutable() + s.mu.Lock() + defer s.mu.Unlock() + accountStorage, ok := s.storage[addr] + if !ok { + accountStorage = map[common.Hash]common.Hash{} + s.storage[addr] = accountStorage + } + if value == (common.Hash{}) { + delete(accountStorage, key) + return + } + accountStorage[key] = value +} + +func (s *generatedState) requireMutable() { + if s.frozen.Load() { + panic("generated state is frozen") + } +} + +func cloneBytes(v []byte) []byte { + if len(v) == 0 { + return nil + } + return append([]byte(nil), v...) +} From 3c58557deb8da91fa5056a0267833edf00e54110 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 5 Aug 2026 12:13:12 +0800 Subject: [PATCH 30/34] count successful evm-only loadtest txs --- giga/evmonly/cmd/evmonly-loadtest/README.md | 2 +- .../evmonly/cmd/evmonly-loadtest/main_test.go | 17 +++++- giga/evmonly/cmd/evmonly-loadtest/metrics.go | 60 ++++++++++++++++--- giga/evmonly/cmd/evmonly-loadtest/pipeline.go | 22 ++++++- 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 14d5c89910..6fbc46fa4b 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -167,7 +167,7 @@ The command reports these saturation signals on stdout and at `/metrics`: - block preparation throughput - prepared transactions per second - block finishing throughput -- finished transactions per second +- finished, successful, and failed transactions per second - total gas consumed per second - total OCC transaction rerun attempts - prepared blocks queued for execution and cumulative totals diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index b5dcf3976c..eda3eb7523 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -501,7 +501,7 @@ func TestPrepareBlocksDrainsPreparedBlocksAfterWorkersFinish(t *testing.T) { func TestLoadMetricsRecordsOCCRerunsWithoutConflicts(t *testing.T) { metrics := newLoadMetrics(prometheus.NewRegistry()) - metrics.recordFinished(4, 84_000, evmonly.OCCStats{ + metrics.recordFinished(txStatusCounts{total: 4, successful: 4}, 84_000, evmonly.OCCStats{ Attempted: true, RerunCount: 3, }) @@ -512,6 +512,21 @@ func TestLoadMetricsRecordsOCCRerunsWithoutConflicts(t *testing.T) { require.Zero(t, snapshot.occConflicts) } +func TestLoadMetricsCountsOnlySuccessfulTxsAsTPS(t *testing.T) { + metrics := newLoadMetrics(prometheus.NewRegistry()) + counts := countTxStatuses([]evmonly.TxResult{ + {Status: ethtypes.ReceiptStatusSuccessful}, + {Status: ethtypes.ReceiptStatusFailed}, + {Status: 99}, + }) + metrics.recordFinished(counts, 63_000, evmonly.OCCStats{}) + + snapshot := metrics.snapshot() + require.Equal(t, uint64(3), snapshot.finishedTxs) + require.Equal(t, uint64(1), snapshot.successfulTxs) + require.Equal(t, uint64(2), snapshot.failedTxs) +} + func TestBlockContextTimestampIsDeterministic(t *testing.T) { cfg, err := parseConfig([]string{"--metrics-addr=", "--blocks=1"}) require.NoError(t, err) diff --git a/giga/evmonly/cmd/evmonly-loadtest/metrics.go b/giga/evmonly/cmd/evmonly-loadtest/metrics.go index d1dd69d1a9..4170fa5ac3 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/metrics.go +++ b/giga/evmonly/cmd/evmonly-loadtest/metrics.go @@ -20,6 +20,8 @@ type loadMetrics struct { preparedTxs atomic.Uint64 finishedBlocks atomic.Uint64 finishedTxs atomic.Uint64 + successfulTxs atomic.Uint64 + failedTxs atomic.Uint64 gasConsumed atomic.Uint64 prepareErrors atomic.Uint64 executionErrors atomic.Uint64 @@ -43,6 +45,8 @@ type loadMetrics struct { preparedTxsTotal prometheus.Counter finishedBlocksTotal prometheus.Counter finishedTxsTotal prometheus.Counter + successfulTxsTotal prometheus.Counter + failedTxsTotal prometheus.Counter gasConsumedTotal prometheus.Counter prepareErrorsTotal prometheus.Counter executionErrorsTotal prometheus.Counter @@ -65,6 +69,8 @@ type loadMetrics struct { preparedTxRate prometheus.Gauge finishedBlockRate prometheus.Gauge txRate prometheus.Gauge + finishedTxRate prometheus.Gauge + failedTxRate prometheus.Gauge gasRate prometheus.Gauge queuedBlocks prometheus.Gauge sinkQueuedRecords prometheus.Gauge @@ -81,6 +87,8 @@ type metricsSnapshot struct { preparedTxs uint64 finishedBlocks uint64 finishedTxs uint64 + successfulTxs uint64 + failedTxs uint64 gasConsumed uint64 prepareErrors uint64 executionErrors uint64 @@ -122,6 +130,14 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { Name: "evmonly_loadtest_transactions_finished_total", Help: "Total transactions that finished EVM-only executor execution.", }), + successfulTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_successful_total", + Help: "Total successful transactions that finished EVM-only executor execution.", + }), + failedTxsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "evmonly_loadtest_transactions_failed_total", + Help: "Total failed transactions that finished EVM-only executor execution.", + }), gasConsumedTotal: prometheus.NewCounter(prometheus.CounterOpts{ Name: "evmonly_loadtest_gas_consumed_total", Help: "Total EVM gas consumed by finished blocks.", @@ -200,7 +216,15 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { }), txRate: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "evmonly_loadtest_transactions_per_second", - Help: "Most recent measured transaction execution throughput in transactions per second.", + Help: "Most recent measured successful transaction execution throughput in transactions per second.", + }), + finishedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_finished_per_second", + Help: "Most recent measured total finished transaction execution throughput in transactions per second.", + }), + failedTxRate: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "evmonly_loadtest_transactions_failed_per_second", + Help: "Most recent measured failed transaction execution throughput in transactions per second.", }), gasRate: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "evmonly_loadtest_gas_consumed_per_second", @@ -237,6 +261,8 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.preparedTxsTotal, m.finishedBlocksTotal, m.finishedTxsTotal, + m.successfulTxsTotal, + m.failedTxsTotal, m.gasConsumedTotal, m.prepareErrorsTotal, m.executionErrorsTotal, @@ -257,6 +283,8 @@ func newLoadMetrics(registry *prometheus.Registry) *loadMetrics { m.preparedTxRate, m.finishedBlockRate, m.txRate, + m.finishedTxRate, + m.failedTxRate, m.gasRate, m.queuedBlocks, m.sinkQueuedRecords, @@ -285,12 +313,16 @@ func (m *loadMetrics) recordPrepareError() { m.prepareErrorsTotal.Inc() } -func (m *loadMetrics) recordFinished(txCount int, gasUsed uint64, occStats evmonly.OCCStats) { +func (m *loadMetrics) recordFinished(counts txStatusCounts, gasUsed uint64, occStats evmonly.OCCStats) { m.finishedBlocks.Add(1) - m.finishedTxs.Add(uint64FromNonNegativeInt(txCount)) + m.finishedTxs.Add(uint64FromNonNegativeInt(counts.total)) + m.successfulTxs.Add(uint64FromNonNegativeInt(counts.successful)) + m.failedTxs.Add(uint64FromNonNegativeInt(counts.failed)) m.gasConsumed.Add(gasUsed) m.finishedBlocksTotal.Inc() - m.finishedTxsTotal.Add(float64(txCount)) + m.finishedTxsTotal.Add(float64(counts.total)) + m.successfulTxsTotal.Add(float64(counts.successful)) + m.failedTxsTotal.Add(float64(counts.failed)) m.gasConsumedTotal.Add(float64(gasUsed)) m.recordOCC(occStats) } @@ -387,6 +419,8 @@ func (m *loadMetrics) snapshot() metricsSnapshot { preparedTxs: m.preparedTxs.Load(), finishedBlocks: m.finishedBlocks.Load(), finishedTxs: m.finishedTxs.Load(), + successfulTxs: m.successfulTxs.Load(), + failedTxs: m.failedTxs.Load(), gasConsumed: m.gasConsumed.Load(), prepareErrors: m.prepareErrors.Load(), executionErrors: m.executionErrors.Load(), @@ -416,13 +450,17 @@ func (m *loadMetrics) setRates(prev, curr metricsSnapshot, queued int) { preparedRate := float64(curr.preparedBlocks-prev.preparedBlocks) / elapsed preparedTxRate := float64(curr.preparedTxs-prev.preparedTxs) / elapsed finishedRate := float64(curr.finishedBlocks-prev.finishedBlocks) / elapsed - txRate := float64(curr.finishedTxs-prev.finishedTxs) / elapsed + finishedTxRate := float64(curr.finishedTxs-prev.finishedTxs) / elapsed + txRate := float64(curr.successfulTxs-prev.successfulTxs) / elapsed + failedTxRate := float64(curr.failedTxs-prev.failedTxs) / elapsed gasRate := float64(curr.gasConsumed-prev.gasConsumed) / elapsed m.inputBlockRate.Set(inputRate) m.preparedBlockRate.Set(preparedRate) m.preparedTxRate.Set(preparedTxRate) m.finishedBlockRate.Set(finishedRate) m.txRate.Set(txRate) + m.finishedTxRate.Set(finishedTxRate) + m.failedTxRate.Set(failedTxRate) m.gasRate.Set(gasRate) m.queuedBlocks.Set(float64(queued)) } @@ -452,12 +490,14 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric sinkWaitSeconds := float64(curr.sinkWaitNanos-prev.sinkWaitNanos) / float64(time.Second) sinkWriteSeconds := float64(curr.sinkWriteNanos-prev.sinkWriteNanos) / float64(time.Second) fmt.Printf( - "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d pool_available=%d pool_overflow=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", + "input_blocks/s=%.2f prepared_blocks/s=%.2f prepared_tx/s=%.2f finished_blocks/s=%.2f finished_tx/s=%.2f success_tx/s=%.2f failed_tx/s=%.2f gas/s=%.2f queued_blocks=%d sink_queue=%d pool_available=%d pool_overflow=%d sink_enqueue_wait/s=%.6f sink_write/s=%.6f totals(input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d successful_txs=%d failed_txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_enqueued=%d sink_written=%d)\n", float64(curr.inputBlocks-prev.inputBlocks)/elapsed, float64(curr.preparedBlocks-prev.preparedBlocks)/elapsed, float64(curr.preparedTxs-prev.preparedTxs)/elapsed, float64(curr.finishedBlocks-prev.finishedBlocks)/elapsed, float64(curr.finishedTxs-prev.finishedTxs)/elapsed, + float64(curr.successfulTxs-prev.successfulTxs)/elapsed, + float64(curr.failedTxs-prev.failedTxs)/elapsed, float64(curr.gasConsumed-prev.gasConsumed)/elapsed, queued, curr.sinkQueued, @@ -470,6 +510,8 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric curr.preparedTxs, curr.finishedBlocks, curr.finishedTxs, + curr.successfulTxs, + curr.failedTxs, curr.gasConsumed, curr.prepareErrors, curr.executionErrors, @@ -490,13 +532,15 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { elapsed = 1 } fmt.Printf( - "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s pool_capacity=%d pool_available=%d pool_overflow=%d avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_tx/s=%.2f avg_gas/s=%.2f\n", + "complete elapsed=%s input_blocks=%d prepared_blocks=%d prepared_txs=%d finished_blocks=%d txs=%d successful_txs=%d failed_txs=%d gas=%d prepare_errors=%d errors=%d occ_attempts=%d occ_fallbacks=%d occ_reruns=%d sink_queue=%d sink_enqueued=%d sink_written=%d sink_bytes=%d sink_enqueue_wait=%s sink_enqueue_wait_events=%d sink_write=%s pool_capacity=%d pool_available=%d pool_overflow=%d avg_input_blocks/s=%.2f avg_prepared_blocks/s=%.2f avg_prepared_tx/s=%.2f avg_finished_blocks/s=%.2f avg_finished_tx/s=%.2f avg_success_tx/s=%.2f avg_failed_tx/s=%.2f avg_gas/s=%.2f\n", snapshot.at.Sub(startedAt).Round(time.Millisecond), snapshot.inputBlocks, snapshot.preparedBlocks, snapshot.preparedTxs, snapshot.finishedBlocks, snapshot.finishedTxs, + snapshot.successfulTxs, + snapshot.failedTxs, snapshot.gasConsumed, snapshot.prepareErrors, snapshot.executionErrors, @@ -518,6 +562,8 @@ func printFinalReport(startedAt time.Time, snapshot metricsSnapshot) { float64(snapshot.preparedTxs)/elapsed, float64(snapshot.finishedBlocks)/elapsed, float64(snapshot.finishedTxs)/elapsed, + float64(snapshot.successfulTxs)/elapsed, + float64(snapshot.failedTxs)/elapsed, float64(snapshot.gasConsumed)/elapsed, ) } diff --git a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go index 9d619cbd1d..7466ad9b3f 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go +++ b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/prometheus/client_golang/prometheus" "github.com/sei-protocol/sei-chain/giga/evmonly" "github.com/sei-protocol/sei-chain/giga/evmonly/cmd/evmonly-loadtest/scenarios" @@ -381,9 +382,28 @@ func executeBlocks( metrics.recordExecutionError() return fmt.Errorf("worker %d execute block %d: %w", workerID, block.number, err) } - metrics.recordFinished(len(result.Txs), result.GasUsed, result.OCCStats) + counts := countTxStatuses(result.Txs) + metrics.recordFinished(counts, result.GasUsed, result.OCCStats) result.Release() metrics.recordResultPoolStats(executor.ResultPoolStats()) } } } + +type txStatusCounts struct { + total int + successful int + failed int +} + +func countTxStatuses(txs []evmonly.TxResult) txStatusCounts { + counts := txStatusCounts{total: len(txs)} + for _, tx := range txs { + if tx.Status == ethtypes.ReceiptStatusSuccessful { + counts.successful++ + } else { + counts.failed++ + } + } + return counts +} From 6dd1f60ec2d4eebc299980139beb56e159f7f5ab Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 5 Aug 2026 12:41:33 +0800 Subject: [PATCH 31/34] reuse sei-load offline scenarios --- app/benchmark/generator.go | 2 +- giga/evmonly/cmd/evmonly-loadtest/README.md | 12 ++-- .../evmonly/cmd/evmonly-loadtest/main_test.go | 15 +++-- .../scenarios/erc20_transfer.go | 51 ++++++----------- .../evmonly-loadtest/scenarios/transfer.go | 40 ++++++++----- .../cmd/evmonly-loadtest/scenarios/types.go | 4 +- go.mod | 32 +++++------ go.sum | 56 +++++++++---------- 8 files changed, 106 insertions(+), 106 deletions(-) diff --git a/app/benchmark/generator.go b/app/benchmark/generator.go index 9fc9119b14..def51a3c79 100644 --- a/app/benchmark/generator.go +++ b/app/benchmark/generator.go @@ -443,7 +443,7 @@ func (g *Generator) transitionToLoadPhase() { panic("benchmark: No scenarios available for load generation") } - g.loadGenerator = generator.NewWeightedGenerator(weightedConfigs...) + g.loadGenerator = generator.NewWeightedGenerator(nil, weightedConfigs...) logger.Info("benchmark: Load generator initialized and ready", "scenarios", len(weightedConfigs)) } diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 6fbc46fa4b..4b723c86bb 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -5,7 +5,8 @@ the EVM-only executor without Cosmos SDK state, mempool, RPC, or chain persistence. It currently generates pure EVM legacy transfer transactions, ERC20 transfer -transactions, and a contract-call workload that exercises nested StateDB +transactions using `sei-load`'s compiled contract runtime, and a contract-call +workload that exercises nested StateDB snapshot/revert behavior. By default, each generated sender account has one nonce-0 transaction and is funded in the command's in-memory genesis state before its block is queued. Recipients are unique by default so the transfer @@ -192,6 +193,9 @@ artifacts and are removed when the process exits normally or handles `SIGINT`/`SIGTERM`. `sink_enqueue_wait` is the primary backpressure signal: a non-zero value means executor workers waited for async sink queue capacity. -Future workloads should add another workload builder beside `transferWorkload`, -`erc20TransferWorkload`, and `snapshotRevertWorkload`, then reuse the same -block producer/prepare/executor/metrics pipeline. +Native and ERC20 transaction construction and genesis seeding come from +`github.com/sei-protocol/sei-load/generator/offline`. Future reusable EVM +workloads should add an offline scenario there and a block/recipient adapter in +this command. Executor-specific workloads can remain beside +`TransferWorkload`, `ERC20TransferWorkload`, and `SnapshotRevertWorkload` and +reuse the same block producer/prepare/executor/metrics pipeline. diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index eda3eb7523..67493e3e00 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -34,7 +34,8 @@ func TestTransferWorkloadExecutesAgainstEVMOnlyExecutor(t *testing.T) { require.NoError(t, err) state := newGeneratedState() - workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) @@ -97,7 +98,8 @@ func TestTransferWorkloadOCCScenarios(t *testing.T) { require.NoError(t, err) state := newGeneratedState() - workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) @@ -273,7 +275,8 @@ func TestTransferWorkloadRecipientConflictRate(t *testing.T) { require.NoError(t, err) state := newGeneratedState() - workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) request, err := workload.BuildBlock(t.Context(), 1) require.NoError(t, err) @@ -719,7 +722,8 @@ func TestExecutorResultPoolReusesSlotsWithFileSink(t *testing.T) { }) require.NoError(t, err) state := newGeneratedState() - workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(t, err) metrics := newLoadMetrics(prometheus.NewRegistry()) sinks, err := newResultSinks(cfg, metrics) require.NoError(t, err) @@ -847,7 +851,8 @@ func BenchmarkExecuteTransferBlock(b *testing.B) { require.NoError(b, err) state := newGeneratedState() - workload := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + workload, err := scenarios.NewTransferWorkload(scenarioConfig(cfg), state) + require.NoError(b, err) request, err := workload.BuildBlock(b.Context(), 1) require.NoError(b, err) executor := evmonly.NewExecutor(evmonly.Config{ diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go index 7b3ca847a9..fdbc83184e 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/erc20_transfer.go @@ -2,38 +2,36 @@ package scenarios import ( "context" - "math/big" "sync/atomic" "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/sei-protocol/sei-chain/giga/evmonly" + loadoffline "github.com/sei-protocol/sei-load/generator/offline" ) type ERC20TransferWorkload struct { cfg Config state State - signer ethtypes.Signer + scenario loadoffline.Scenario conflictParticipants int accountCursor atomic.Uint64 } -var ( - erc20TransferSelector = [4]byte{0xa9, 0x05, 0x9c, 0xbb} - // Minimal ERC20-like runtime for transfer(address,uint256), with balances at - // storage slot 0 and a standard Transfer(address,address,uint256) log. - erc20TransferRuntimeCode = common.FromHex("0x60003560e01c63a9059cbb1460145760006000fd5b60243560043533600052600060205260406000208054831060805780548303905580600052600060205260406000208054830190558160005280337fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60206000a3600160005260206000f35b60006000fd") -) - -func NewERC20TransferWorkload(cfg Config, state State) *ERC20TransferWorkload { - state.SetCode(cfg.ERC20Contract, erc20TransferRuntimeCode) +func NewERC20TransferWorkload(cfg Config, state State) (*ERC20TransferWorkload, error) { + scenario, err := loadoffline.NewScenario(loadoffline.ERC20Transfer, offlineConfig(cfg)) + if err != nil { + return nil, err + } + if err := scenario.SetupGenesis(state); err != nil { + return nil, err + } return &ERC20TransferWorkload{ cfg: cfg, state: state, - signer: ethtypes.LatestSignerForChainID(cfg.ChainID), + scenario: scenario, conflictParticipants: recipientConflictParticipants(cfg.TxsPerBlock, cfg.RecipientConflictRate), - } + }, nil } func (w *ERC20TransferWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { @@ -50,8 +48,7 @@ func (w *ERC20TransferWorkload) BuildBlock(ctx context.Context, number uint64) ( if err != nil { return evmonly.BlockRequest{}, err } - w.state.SetBalance(sender, w.cfg.SenderBalance) - w.state.SetState(w.cfg.ERC20Contract, ERC20BalanceSlot(sender), common.BigToHash(w.cfg.TransferValue)) + w.scenario.SeedSender(w.state, sender) txs[i] = raw } return evmonly.BlockRequest{ @@ -66,15 +63,7 @@ func (w *ERC20TransferWorkload) buildTransferTx(accountIndex uint64, recipient c return nil, common.Address{}, err } sender := crypto.PubkeyToAddress(key.PublicKey) - tx := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: 0, - GasPrice: new(big.Int).Set(w.cfg.GasPrice), - Gas: w.cfg.TxGasLimit, - To: &w.cfg.ERC20Contract, - Value: new(big.Int), - Data: erc20TransferCalldata(recipient, w.cfg.TransferValue), - }) - signed, err := ethtypes.SignTx(tx, w.signer, key) + signed, err := w.scenario.BuildTransaction(key, 0, recipient) if err != nil { return nil, common.Address{}, err } @@ -88,15 +77,7 @@ func (w *ERC20TransferWorkload) buildTransferTx(accountIndex uint64, recipient c func (w *ERC20TransferWorkload) Recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-erc20-recipient", "sei-evmonly-loadtest-erc20-conflict-recipient", blockNumber, txIndex, accountIndex) } -func erc20TransferCalldata(recipient common.Address, amount *big.Int) []byte { - data := make([]byte, 4+32+32) - copy(data[:4], erc20TransferSelector[:]) - copy(data[4+12:36], recipient.Bytes()) - amount.FillBytes(data[36:68]) - return data -} + func ERC20BalanceSlot(owner common.Address) common.Hash { - var encoded [64]byte - copy(encoded[12:32], owner.Bytes()) - return crypto.Keccak256Hash(encoded[:]) + return loadoffline.ERC20BalanceSlot(owner) } diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go index 6fb985920d..ae2cf61065 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/transfer.go @@ -2,30 +2,36 @@ package scenarios import ( "context" - "math/big" "sync/atomic" "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/sei-protocol/sei-chain/giga/evmonly" + loadoffline "github.com/sei-protocol/sei-load/generator/offline" ) type TransferWorkload struct { cfg Config state State - signer ethtypes.Signer + scenario loadoffline.Scenario conflictParticipants int accountCursor atomic.Uint64 } -func NewTransferWorkload(cfg Config, state State) *TransferWorkload { +func NewTransferWorkload(cfg Config, state State) (*TransferWorkload, error) { + scenario, err := loadoffline.NewScenario(loadoffline.Transfer, offlineConfig(cfg)) + if err != nil { + return nil, err + } + if err := scenario.SetupGenesis(state); err != nil { + return nil, err + } return &TransferWorkload{ cfg: cfg, state: state, - signer: ethtypes.LatestSignerForChainID(cfg.ChainID), + scenario: scenario, conflictParticipants: recipientConflictParticipants(cfg.TxsPerBlock, cfg.RecipientConflictRate), - } + }, nil } func (w *TransferWorkload) BuildBlock(ctx context.Context, number uint64) (evmonly.BlockRequest, error) { @@ -48,7 +54,7 @@ func (w *TransferWorkload) BuildBlock(ctx context.Context, number uint64) (evmon if err != nil { return evmonly.BlockRequest{}, err } - w.state.SetBalance(sender, w.cfg.SenderBalance) + w.scenario.SeedSender(w.state, sender) txs[i] = raw } return evmonly.BlockRequest{ @@ -63,14 +69,7 @@ func (w *TransferWorkload) buildTransferTx(accountIndex, nonce uint64, recipient return nil, common.Address{}, err } sender := crypto.PubkeyToAddress(key.PublicKey) - tx := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: nonce, - GasPrice: new(big.Int).Set(w.cfg.GasPrice), - Gas: w.cfg.TxGasLimit, - To: &recipient, - Value: new(big.Int).Set(w.cfg.TransferValue), - }) - signed, err := ethtypes.SignTx(tx, w.signer, key) + signed, err := w.scenario.BuildTransaction(key, nonce, recipient) if err != nil { return nil, common.Address{}, err } @@ -84,3 +83,14 @@ func (w *TransferWorkload) buildTransferTx(accountIndex, nonce uint64, recipient func (w *TransferWorkload) Recipient(blockNumber uint64, txIndex int, accountIndex uint64) common.Address { return workloadRecipient(w.cfg, w.conflictParticipants, "sei-evmonly-loadtest-recipient", "sei-evmonly-loadtest-conflict-recipient", blockNumber, txIndex, accountIndex) } + +func offlineConfig(cfg Config) loadoffline.Config { + return loadoffline.Config{ + ChainID: cfg.ChainID, + GasPrice: cfg.GasPrice, + SenderBalance: cfg.SenderBalance, + TransferValue: cfg.TransferValue, + GasLimit: cfg.TxGasLimit, + ERC20Contract: cfg.ERC20Contract, + } +} diff --git a/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go b/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go index 4a513aaacb..294f963ef7 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go +++ b/giga/evmonly/cmd/evmonly-loadtest/scenarios/types.go @@ -47,9 +47,9 @@ type Workload interface { func NewWorkload(kind string, cfg Config, state State) (Workload, error) { switch kind { case WorkloadTransfer: - return NewTransferWorkload(cfg, state), nil + return NewTransferWorkload(cfg, state) case WorkloadERC20Transfer: - return NewERC20TransferWorkload(cfg, state), nil + return NewERC20TransferWorkload(cfg, state) case WorkloadSnapshotRevert: return NewSnapshotRevertWorkload(cfg, state), nil default: diff --git a/go.mod b/go.mod index 0caea25634..a275fca925 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,7 @@ require ( github.com/segmentio/kafka-go v0.4.50 github.com/sei-protocol/goutils v0.0.2 github.com/sei-protocol/sei-config v0.0.21 - github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 + github.com/sei-protocol/sei-load v0.0.0-20260805043403-e5d7c2f19be9 github.com/sei-protocol/sei-tm-db v0.0.5 github.com/sei-protocol/seilog v0.0.3 github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa @@ -89,21 +89,21 @@ require ( github.com/tidwall/wal v1.2.1 github.com/zbiljic/go-filelock v0.0.0-20170914061330-1dbf7103ab7d github.com/zeebo/blake3 v0.2.4 - go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/jaeger v1.9.0 go.opentelemetry.io/otel/exporters/prometheus v0.60.0 - go.opentelemetry.io/otel/metric v1.39.0 - go.opentelemetry.io/otel/sdk v1.39.0 - go.opentelemetry.io/otel/sdk/metric v1.39.0 - go.opentelemetry.io/otel/trace v1.39.0 - golang.org/x/crypto v0.47.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/crypto v0.49.0 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 - golang.org/x/net v0.49.0 - golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 + golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.42.0 golang.org/x/time v0.13.0 - google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 - google.golang.org/grpc v1.75.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -265,11 +265,11 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/image v0.36.0 - golang.org/x/mod v0.32.0 - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/mod v0.33.0 + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gotest.tools v2.2.0+incompatible // indirect nhooyr.io/websocket v1.8.6 // indirect diff --git a/go.sum b/go.sum index cfab424426..5d628ded50 100644 --- a/go.sum +++ b/go.sum @@ -1813,8 +1813,8 @@ github.com/sei-protocol/goutils v0.0.2 h1:Bfa7Sv+4CVLNM20QcpvGb81B8C5HkQC/kW1CQp github.com/sei-protocol/goutils v0.0.2/go.mod h1:iYE2DuJfEnM+APPehr2gOUXfuLuPsVxorcDO+Tzq9q8= github.com/sei-protocol/sei-config v0.0.21 h1:0VXOz91v9YeM2dmpKi1XaIFy+p3EamDAJtnj02XLKIw= github.com/sei-protocol/sei-config v0.0.21/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= -github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 h1:f2sY8OcN60UL1/6POx+HDMZ4w04FTZtSScnrFSnGZHg= -github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082/go.mod h1:V0fNURAjS6A8+sA1VllegjNeSobay3oRUW5VFZd04bA= +github.com/sei-protocol/sei-load v0.0.0-20260805043403-e5d7c2f19be9 h1:EMBaskpKgbohE6b6leYyGVdlP8JVzlsbH8THUzKVFQk= +github.com/sei-protocol/sei-load v0.0.0-20260805043403-e5d7c2f19be9/go.mod h1:AjEkVo91siRmr5r7REcK3fSSbHC59hq+nCNtxwJUG8Q= github.com/sei-protocol/sei-tm-db v0.0.5 h1:3WONKdSXEqdZZeLuWYfK5hP37TJpfaUa13vAyAlvaQY= github.com/sei-protocol/sei-tm-db v0.0.5/go.mod h1:Cpa6rGyczgthq7/0pI31jys2Fw0Nfrc+/jKdP1prVqY= github.com/sei-protocol/seilog v0.0.3 h1:Zi7oWXdX5jv92dY8n482xH032LtNebC89Y+qYZlBn0Y= @@ -2061,20 +2061,20 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/jaeger v1.9.0 h1:gAEgEVGDWwFjcis9jJTOJqZNxDzoZfR12WNIxr7g9Ww= go.opentelemetry.io/otel/exporters/jaeger v1.9.0/go.mod h1:hquezOLVAybNW6vanIxkdLXTXvzlj2Vn3wevSP15RYs= go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo= go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= @@ -2176,8 +2176,8 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2263,8 +2263,8 @@ golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20170207211851-4464e7848382/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2317,8 +2317,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -2459,8 +2459,8 @@ golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -2482,8 +2482,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2507,8 +2507,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2829,12 +2829,12 @@ google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuO google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go.mod h1:ts19tUU+Z0ZShN1y3aPyq2+O3d5FUNNgT6FtOzmrNn8= google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.57.1 h1:upNTNqv0ES+2ZOOqACwVtS3Il8M12/+Hz41RCPzAjQg= google.golang.org/grpc v1.57.1/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= From cf656deddea35358ce71638da1cb62865fdaae53 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 5 Aug 2026 13:08:52 +0800 Subject: [PATCH 32/34] address evm-only loadtest review feedback --- giga/evmonly/cmd/evmonly-loadtest/README.md | 3 +++ giga/evmonly/cmd/evmonly-loadtest/config.go | 2 +- giga/evmonly/cmd/evmonly-loadtest/main_test.go | 6 ++++++ giga/evmonly/cmd/evmonly-loadtest/metrics.go | 2 +- giga/evmonly/cmd/evmonly-loadtest/pipeline.go | 18 +++++++++--------- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/giga/evmonly/cmd/evmonly-loadtest/README.md b/giga/evmonly/cmd/evmonly-loadtest/README.md index 4b723c86bb..8226b8c7e3 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/README.md +++ b/giga/evmonly/cmd/evmonly-loadtest/README.md @@ -4,6 +4,9 @@ the EVM-only executor without Cosmos SDK state, mempool, RPC, or chain persistence. +The synthetic workload defaults to local EVM chain ID `1337`; override it with +`--chain-id` when testing another signing domain. + It currently generates pure EVM legacy transfer transactions, ERC20 transfer transactions using `sei-load`'s compiled contract runtime, and a contract-call workload that exercises nested StateDB diff --git a/giga/evmonly/cmd/evmonly-loadtest/config.go b/giga/evmonly/cmd/evmonly-loadtest/config.go index a02e9bde0c..58965a7f5e 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/config.go +++ b/giga/evmonly/cmd/evmonly-loadtest/config.go @@ -16,7 +16,7 @@ import ( ) const ( - defaultChainID = "713715" + defaultChainID = "1337" defaultGasPriceWei = "1000000000" defaultMinGasPriceWei = "1000000000" defaultSenderBalance = "1000000000000000000" diff --git a/giga/evmonly/cmd/evmonly-loadtest/main_test.go b/giga/evmonly/cmd/evmonly-loadtest/main_test.go index 67493e3e00..8b76c37897 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/main_test.go +++ b/giga/evmonly/cmd/evmonly-loadtest/main_test.go @@ -331,6 +331,12 @@ func TestBlocksRequiresBoundedRun(t *testing.T) { require.ErrorContains(t, err, "blocks must be positive") } +func TestDefaultChainIDIsLocal(t *testing.T) { + cfg, err := parseConfig([]string{"--blocks=1"}) + require.NoError(t, err) + require.Equal(t, "1337", cfg.chainID.String()) +} + func TestRecipientConflictRateValidation(t *testing.T) { _, err := parseConfig([]string{ "--blocks=1", diff --git a/giga/evmonly/cmd/evmonly-loadtest/metrics.go b/giga/evmonly/cmd/evmonly-loadtest/metrics.go index 4170fa5ac3..6bf7e41c7a 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/metrics.go +++ b/giga/evmonly/cmd/evmonly-loadtest/metrics.go @@ -474,7 +474,7 @@ func reportLoop(ctx context.Context, interval time.Duration, metrics *loadMetric defer ticker.Stop() prev := metrics.snapshot() - for { + for ctx.Err() == nil { select { case <-ctx.Done(): return diff --git a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go index 7466ad9b3f..8d2fdf6ae7 100644 --- a/giga/evmonly/cmd/evmonly-loadtest/pipeline.go +++ b/giga/evmonly/cmd/evmonly-loadtest/pipeline.go @@ -164,7 +164,7 @@ func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorklo group, groupCtx := errgroup.WithContext(ctx) for builderID := 0; builderID < cfg.builders; builderID++ { group.Go(func() error { - for { + for groupCtx.Err() == nil { number := nextBlock.Add(1) if number > cfg.blocks { return nil @@ -181,6 +181,7 @@ func prebuildBlockRequests(ctx context.Context, cfg config, workload blockWorklo request: request, } } + return nil }) } if err := group.Wait(); err != nil { @@ -216,7 +217,7 @@ func prepareBlocks( for workerID := 0; workerID < cfg.prepareWorkers; workerID++ { workerID := workerID group.Go(func() error { - for { + for groupCtx.Err() == nil { select { case <-groupCtx.Done(): return nil @@ -240,6 +241,7 @@ func prepareBlocks( } } } + return nil }) } done := make(chan error, 1) @@ -265,12 +267,8 @@ func prepareBlocks( func forwardPreparedBlocksInOrder(ctx context.Context, in <-chan preparedBlockEnvelope, out chan<- preparedBlockEnvelope) error { next := uint64(1) pending := make(map[uint64]preparedBlockEnvelope) - for { - for { - block, ok := pending[next] - if !ok { - break - } + for ctx.Err() == nil { + for block, ok := pending[next]; ok; block, ok = pending[next] { if err := sendPreparedBlock(ctx, out, block); err != nil { return err } @@ -315,6 +313,7 @@ func forwardPreparedBlocksInOrder(ctx context.Context, in <-chan preparedBlockEn pending[block.number] = block } } + return nil } func sendPreparedBlock(ctx context.Context, out chan<- preparedBlockEnvelope, block preparedBlockEnvelope) error { @@ -366,7 +365,7 @@ func executeBlocks( blocks <-chan preparedBlockEnvelope, metrics *loadMetrics, ) error { - for { + for ctx.Err() == nil { select { case <-ctx.Done(): return nil @@ -388,6 +387,7 @@ func executeBlocks( metrics.recordResultPoolStats(executor.ResultPoolStats()) } } + return nil } type txStatusCounts struct { From 4bc9274664325158d57616afef3c893291401aa6 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 6 Aug 2026 11:25:54 +0800 Subject: [PATCH 33/34] use sei-load v0.0.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a275fca925..fc7540b492 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,7 @@ require ( github.com/segmentio/kafka-go v0.4.50 github.com/sei-protocol/goutils v0.0.2 github.com/sei-protocol/sei-config v0.0.21 - github.com/sei-protocol/sei-load v0.0.0-20260805043403-e5d7c2f19be9 + github.com/sei-protocol/sei-load v0.0.1 github.com/sei-protocol/sei-tm-db v0.0.5 github.com/sei-protocol/seilog v0.0.3 github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa diff --git a/go.sum b/go.sum index 5d628ded50..311d7c986e 100644 --- a/go.sum +++ b/go.sum @@ -1813,8 +1813,8 @@ github.com/sei-protocol/goutils v0.0.2 h1:Bfa7Sv+4CVLNM20QcpvGb81B8C5HkQC/kW1CQp github.com/sei-protocol/goutils v0.0.2/go.mod h1:iYE2DuJfEnM+APPehr2gOUXfuLuPsVxorcDO+Tzq9q8= github.com/sei-protocol/sei-config v0.0.21 h1:0VXOz91v9YeM2dmpKi1XaIFy+p3EamDAJtnj02XLKIw= github.com/sei-protocol/sei-config v0.0.21/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= -github.com/sei-protocol/sei-load v0.0.0-20260805043403-e5d7c2f19be9 h1:EMBaskpKgbohE6b6leYyGVdlP8JVzlsbH8THUzKVFQk= -github.com/sei-protocol/sei-load v0.0.0-20260805043403-e5d7c2f19be9/go.mod h1:AjEkVo91siRmr5r7REcK3fSSbHC59hq+nCNtxwJUG8Q= +github.com/sei-protocol/sei-load v0.0.1 h1:sGlG1QRbc2pKCeeD4P0H2lkqFcljlup5l6JNi6HydyM= +github.com/sei-protocol/sei-load v0.0.1/go.mod h1:AjEkVo91siRmr5r7REcK3fSSbHC59hq+nCNtxwJUG8Q= github.com/sei-protocol/sei-tm-db v0.0.5 h1:3WONKdSXEqdZZeLuWYfK5hP37TJpfaUa13vAyAlvaQY= github.com/sei-protocol/sei-tm-db v0.0.5/go.mod h1:Cpa6rGyczgthq7/0pI31jys2Fw0Nfrc+/jKdP1prVqY= github.com/sei-protocol/seilog v0.0.3 h1:Zi7oWXdX5jv92dY8n482xH032LtNebC89Y+qYZlBn0Y= From 201f2e2081eb43c2e920d184677c7fbd6fe36ed7 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 6 Aug 2026 15:44:15 +0800 Subject: [PATCH 34/34] fix tracing batch processor characterization --- sei-cosmos/baseapp/config_fuzz_test.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/sei-cosmos/baseapp/config_fuzz_test.go b/sei-cosmos/baseapp/config_fuzz_test.go index 1dd9cbbaf2..c6a5dda3dc 100644 --- a/sei-cosmos/baseapp/config_fuzz_test.go +++ b/sei-cosmos/baseapp/config_fuzz_test.go @@ -475,7 +475,7 @@ func TestBaseAppTracingEnabledInstallsAProviderNothingShutsDown(t *testing.T) { } }) - leaked := batchSpanProcessorGoroutines() - exportersBefore + leaked := waitForBatchSpanProcessorGoroutines(exportersBefore+1) - exportersBefore if leaked != 1 { t.Errorf("tracing = true started %d batch-processor goroutines, want 1. Zero has four causes "+ "and this line cannot tell them apart: construction now shuts the provider down, the "+ @@ -520,7 +520,7 @@ func TestBaseAppTracingEnabledInstallsAProviderNothingShutsDown(t *testing.T) { // this fails too, and on a change in sei only the assertions above do. controlBefore := batchSpanProcessorGoroutines() control := sdktrace.NewTracerProvider(sdktrace.WithBatcher(tracetest.NewInMemoryExporter())) - if got := batchSpanProcessorGoroutines() - controlBefore; got != 1 { + if got := waitForBatchSpanProcessorGoroutines(controlBefore+1) - controlBefore; got != 1 { t.Errorf("a provider built with exactly one batcher moved the count by %d, want 1. The frame "+ "batchSpanProcessorGoroutines matches no longer names a batch-processor goroutine, so "+ "every count in this row reads zero and none of its assertions can fail", got) @@ -540,15 +540,14 @@ func TestBaseAppTracingEnabledInstallsAProviderNothingShutsDown(t *testing.T) { // Every trace.WithBatcher starts exactly one and only Shutdown stops it, so the count is how // the row above sees a provider nobody owns rather than describing one. // -// It counts the creator line the runtime records for each such goroutine. That line is present -// from creation rather than from first scheduling, since runtime.Stack dumps runnable goroutines -// too and the creator PC is set before the new goroutine becomes runnable; it appears exactly -// once per goroutine; and it names an exported constructor rather than an internal. If the SDK -// renames it this reads zero for every provider, sei's and the row's control alike — which is -// why the row above pairs it with one, since a zero delta on its own says nothing about whether -// sei stopped leaking. +// It counts the processQueue frame that remains on the stack for the goroutine's lifetime. The +// SDK starts that goroutine through sync.WaitGroup.Go, so its creator frame no longer identifies +// NewBatchSpanProcessor. The startup assertions poll until processQueue is scheduled. If the SDK +// renames it this reads zero for every provider, sei's and the row's control alike — which is why +// the row above pairs it with one, since a zero delta on its own says nothing about whether sei +// stopped leaking. func batchSpanProcessorGoroutines() int { - const frame = "created by go.opentelemetry.io/otel/sdk/trace.NewBatchSpanProcessor" + const frame = "go.opentelemetry.io/otel/sdk/trace.(*batchSpanProcessor).processQueue" buf := make([]byte, 1<<16) for { n := runtime.Stack(buf, true) @@ -560,9 +559,9 @@ func batchSpanProcessorGoroutines() int { } // waitForBatchSpanProcessorGoroutines polls until the count reaches want, or gives up and -// reports what it saw. Shutdown returns once the processor has signalled completion, which is -// a moment before the goroutine itself finishes unwinding, so an immediate read can still see -// it and the wait is what keeps that from being a flake. +// reports what it saw. A new processor may not have reached processQueue when construction +// returns. Shutdown can return a moment before that frame finishes unwinding. Waiting keeps +// both transitions from making the characterization flaky. func waitForBatchSpanProcessorGoroutines(want int) int { deadline := time.Now().Add(5 * time.Second) for {