From 3a3a348b85677526e7d24fb9283bc34c53a08be2 Mon Sep 17 00:00:00 2001 From: MariusVanDerWijden Date: Wed, 17 Jun 2026 09:20:30 +0200 Subject: [PATCH] core: experiment with reducing allocs for transfers --- core/state_processor.go | 161 ++++++++++++++++++++++++++++- core/state_processor_bench_test.go | 120 +++++++++++++++++++++ internal/telemetry/telemetry.go | 7 ++ 3 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 core/state_processor_bench_test.go diff --git a/core/state_processor.go b/core/state_processor.go index f40aee03010e..236ff8b08ba1 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -97,15 +97,31 @@ func (p *StateProcessor) Process(ctx context.Context, block *types.Block, stated // Iterate over and process the individual transactions for i, tx := range block.Transactions() { + statedb.SetTxContext(tx.Hash(), i, uint32(i+1)) + + // Fast path: a bare 21000-gas value transfer to a code-less account has + // a fully deterministic outcome and can be applied without converting the + // transaction to a Message or spinning up the state-transition machinery. + if receipt, bal, ok := tryFastTransfer(tx, signer, gp, statedb, config, blockNumber, blockHash, context, header.BaseFee); ok { + receipts = append(receipts, receipt) + allLogs = append(allLogs, receipt.Logs...) + blockAccessList.Merge(bal) + continue + } + msg, err := TransactionToMessage(tx, signer, header.BaseFee) if err != nil { return nil, fmt.Errorf("could not apply tx %d [%v]: %w", i, tx.Hash().Hex(), err) } - statedb.SetTxContext(tx.Hash(), i, uint32(i+1)) - _, _, spanEnd := telemetry.StartSpan(ctx, "core.ApplyTransactionWithEVM", - telemetry.StringAttribute("tx.hash", tx.Hash().Hex()), - telemetry.IntAttribute("tx.index", i), - ) + // Only build the per-tx span (and its attributes, which allocate) when + // tracing is actually active; in the common case it is not. + spanEnd := func(*error) {} + if telemetry.IsRecording(ctx) { + _, _, spanEnd = telemetry.StartSpan(ctx, "core.ApplyTransactionWithEVM", + telemetry.StringAttribute("tx.hash", tx.Hash().Hex()), + telemetry.IntAttribute("tx.index", i), + ) + } receipt, bal, err := ApplyTransactionWithEVM(msg, gp, statedb, blockNumber, blockHash, context.Time, tx, evm) if err != nil { spanEnd(&err) @@ -220,6 +236,141 @@ func ApplyTransactionWithEVM(msg *Message, gp *GasPool, statedb *state.StateDB, return MakeReceipt(evm, result, statedb, blockNumber, blockHash, blockTime, tx, gp.CumulativeUsed(), root), bal, nil } +// tryFastTransfer handles the common case of a simple value transfer between +// externally-owned accounts without converting the transaction to a Message or +// invoking the EVM. It returns ok=false (and the caller falls back to the full +// state transition) unless every condition below holds, in which case the +// outcome is provably identical to running the message through +// stateTransition.execute: +// +// - the tx is a plain transfer: legacy/dynamic-fee/access-list type, non-nil +// recipient, no calldata, no access list (so intrinsic gas == 21000); +// - the gas limit equals the intrinsic transfer cost (21000), so no gas is +// forwarded to a frame, no refund can arise and the EIP-7623 floor is moot; +// - the chain is pre-Amsterdam / non-Verkle and no tracer is attached, so +// there are no EVM hooks, transfer/burn logs, 2D gas accounting or +// witness-building side effects to reproduce; +// - the sender passes the standard pre-checks (nonce, EOA, fee cap) and can +// afford gas + value; +// - the recipient already exists and carries no code (no contract, no +// delegation), so no account-creation charge and no code execution occur. +// +// The fast path is checked before TransactionToMessage so that the (allocating) +// big.Int -> uint256 conversion of the fee fields is avoided entirely. +func tryFastTransfer(tx *types.Transaction, signer types.Signer, gp *GasPool, statedb *state.StateDB, config *params.ChainConfig, blockNumber *big.Int, blockHash common.Hash, blockCtx vm.BlockContext, baseFee *big.Int) (*types.Receipt, *bal.ConstructionBlockAccessList, bool) { + // Cheap, allocation-free shape checks first. Only plain transfer-shaped + // transactions with the exact intrinsic gas limit qualify. + switch tx.Type() { + case types.LegacyTxType, types.AccessListTxType, types.DynamicFeeTxType: + default: + return nil, nil, false + } + to := tx.To() + if to == nil || tx.Gas() != params.TxGas || len(tx.Data()) != 0 || len(tx.AccessList()) != 0 { + return nil, nil, false + } + // The fast path only mirrors the simple pre-Amsterdam, non-Verkle accounting + // and produces no tracer events. Byzantium is required so the receipt carries + // a status flag rather than an intermediate state root. + rules := config.Rules(blockNumber, blockCtx.Random != nil, blockCtx.Time) + if rules.IsAmsterdam || rules.IsEIP4762 || !rules.IsByzantium { + return nil, nil, false + } + + // Recover the sender (cached on the tx, and required by the full path too). + from, err := types.Sender(signer, tx) + if err != nil { + return nil, nil, false + } + // Nonce must match exactly; mismatches fall through to surface the error. + if statedb.GetNonce(from) != tx.Nonce() { + return nil, nil, false + } + // Sender must be a plain EOA, recipient must already exist with no code. + if statedb.GetCodeSize(from) != 0 { + return nil, nil, false + } + if statedb.GetCodeSize(*to) != 0 || !statedb.Exist(*to) { + return nil, nil, false + } + + // Compute the effective gas price and effective tip, mirroring + // TransactionToMessage and stateTransition.execute. Pre-London the effective + // price is simply the gas price. Under London it is min(feeCap, baseFee+tip), + // the feeCap must be >= the tip and >= the base fee, and the tip paid to the + // coinbase is effectivePrice - baseFee. + feeCap, o1 := uint256.FromBig(tx.GasFeeCap()) + tipCap, o2 := uint256.FromBig(tx.GasTipCap()) + if o1 || o2 { + return nil, nil, false + } + gasPrice := feeCap + effectiveTip := feeCap + if rules.IsLondon { + base, o3 := uint256.FromBig(baseFee) + if o3 || feeCap.Lt(tipCap) || feeCap.Lt(base) { + return nil, nil, false + } + gasPrice = new(uint256.Int).Add(base, tipCap) + if gasPrice.Gt(feeCap) { + gasPrice = feeCap + } + effectiveTip = new(uint256.Int).Sub(gasPrice, base) + } + + value, overflow := uint256.FromBig(tx.Value()) + if overflow { + return nil, nil, false + } + // Cost = gas*price + value; the sender must cover all of it. + gasCost := new(uint256.Int).SetUint64(params.TxGas) + if _, o := gasCost.MulOverflow(gasCost, gasPrice); o { + return nil, nil, false + } + total := new(uint256.Int) + if _, o := total.AddOverflow(gasCost, value); o { + return nil, nil, false + } + if statedb.GetBalance(from).Cmp(total) < 0 { + return nil, nil, false + } + // Reserve gas in the block pool; if the block is full, defer to the full path + // so it reports ErrGasLimitReached identically. + if err := gp.CheckGasLegacy(params.TxGas); err != nil { + return nil, nil, false + } + + // --- apply (all checks passed; outcome is deterministic) --- + statedb.SetNonce(from, tx.Nonce()+1, tracing.NonceChangeEoACall) + statedb.SubBalance(from, gasCost, tracing.BalanceDecreaseGasBuy) + if !value.IsZero() { + statedb.SubBalance(from, value, tracing.BalanceChangeTransfer) + statedb.AddBalance(*to, value, tracing.BalanceChangeTransfer) + } + // Pay the effective tip (gasPrice - baseFee under London) to the coinbase. + fee := new(uint256.Int).Mul(uint256.NewInt(params.TxGas), effectiveTip) + statedb.AddBalance(blockCtx.Coinbase, fee, tracing.BalanceIncreaseRewardTransactionFee) + + // Charge the block gas pool (no refund, gas used == intrinsic). + if err := gp.ChargeGasLegacy(0, params.TxGas); err != nil { + return nil, nil, false // should be unreachable; fall back to be safe + } + balList := statedb.Finalise(true) + + receipt := &types.Receipt{ + Type: tx.Type(), + Status: types.ReceiptStatusSuccessful, + CumulativeGasUsed: gp.CumulativeUsed(), + GasUsed: params.TxGas, + TxHash: tx.Hash(), + BlockHash: blockHash, + BlockNumber: blockNumber, + TransactionIndex: uint(statedb.TxIndex()), + } + receipt.Bloom = types.CreateBloom(receipt) + return receipt, balList, true +} + // MakeReceipt generates the receipt object for a transaction given its execution result. func MakeReceipt(evm *vm.EVM, result *ExecutionResult, statedb *state.StateDB, blockNumber *big.Int, blockHash common.Hash, blockTime uint64, tx *types.Transaction, cumulativeGas uint64, root []byte) *types.Receipt { // Create a new receipt for the transaction, storing the intermediate root diff --git a/core/state_processor_bench_test.go b/core/state_processor_bench_test.go new file mode 100644 index 000000000000..140edf6fb97d --- /dev/null +++ b/core/state_processor_bench_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package core + +import ( + "context" + "crypto/ecdsa" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/beacon" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" +) + +// benchPureTransferBlock generates a genesis spec and a single block packed +// with as many 21000-gas pure value transfers as fit in the block gas limit. +// All recipients are freshly-derived EOAs with no code, so every transaction +// is a "pure transfer". +func benchPureTransferBlock(b *testing.B, naccounts int) (*Genesis, *types.Block) { + // Derive a set of funded keys. + keys := make([]*ecdsa.PrivateKey, naccounts) + addrs := make([]common.Address, naccounts) + keys[0] = benchRootKey + addrs[0] = benchRootAddr + for i := 1; i < naccounts; i++ { + k, _ := crypto.GenerateKey() + keys[i] = k + addrs[i] = crypto.PubkeyToAddress(k.PublicKey) + } + + alloc := make(types.GenesisAlloc, naccounts) + for _, a := range addrs { + alloc[a] = types.Account{Balance: benchRootFunds} + } + gspec := &Genesis{ + Config: params.TestChainConfig, + GasLimit: 200_000_000, + Alloc: alloc, + } + + from := 0 + _, blocks, _ := GenerateChainWithGenesis(gspec, beacon.New(ethash.NewFaker()), 1, func(i int, gen *BlockGen) { + gas := gen.header.GasLimit + gasPrice := big.NewInt(0) + if gen.header.BaseFee != nil { + gasPrice = gen.header.BaseFee + } + signer := gen.Signer() + for gas >= params.TxGas { + gas -= params.TxGas + to := addrs[(from+1)%naccounts] + tx, err := types.SignNewTx(keys[from], signer, &types.LegacyTx{ + Nonce: gen.TxNonce(addrs[from]), + To: &to, + Value: big.NewInt(1), + Gas: params.TxGas, + GasPrice: gasPrice, + }) + if err != nil { + b.Fatal(err) + } + gen.AddTx(tx) + from = (from + 1) % naccounts + } + }) + return gspec, blocks[0] +} + +// benchmarkProcess builds a block of pure transfers and times repeated +// Process calls against a fresh state derived from genesis each iteration, +// isolating the cost of execution from disk commit / trie hashing. +func benchmarkProcess(b *testing.B, naccounts int) { + gspec, block := benchPureTransferBlock(b, naccounts) + + db := rawdb.NewMemoryDatabase() + chain, err := NewBlockChain(db, gspec, beacon.New(ethash.NewFaker()), nil) + if err != nil { + b.Fatal(err) + } + defer chain.Stop() + + processor := chain.Processor() + genesisHeader := chain.Genesis().Header() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + statedb, err := chain.StateAt(genesisHeader) + if err != nil { + b.Fatal(err) + } + if _, err := processor.Process(context.Background(), block, statedb, nil, vm.Config{}); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkProcessPureTransfers(b *testing.B) { + benchmarkProcess(b, 1000) +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index ed598064b3d0..8644a91447a1 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -50,6 +50,13 @@ func BoolAttribute(key string, val bool) Attribute { return attribute.Bool(key, val) } +// IsRecording reports whether the context carries a valid parent span, i.e. +// whether a span started now would actually be exported. Hot paths can use this +// to avoid building span attributes (which may allocate) when tracing is off. +func IsRecording(ctx context.Context) bool { + return trace.SpanFromContext(ctx).SpanContext().IsValid() +} + // StartSpan creates a SpanKind=INTERNAL span. func StartSpan(ctx context.Context, spanName string, attributes ...Attribute) (context.Context, trace.Span, func(*error)) { return StartSpanWithTracer(ctx, otel.Tracer(""), spanName, attributes...)