mirror of
https://github.com/ethereum/go-ethereum.git
synced 2026-05-08 09:11:34 +00:00
bal: lazy scope allocation in access list builder (#34019)
## Summary - Avoid allocating a `map[common.Address]*constructionAccountAccess` on every `enterScope()` call in the BAL access list builder - Push `nil` onto the stack and lazily allocate via `currentScope()` only when a state change occurs - `exitScope()` short-circuits when child scope is nil (no merge needed) - Adds correctness test (`TestLazyScopeCorrectness`) and benchmark (`BenchmarkPrecompileScopes`) ## Context The [execution-specs benchmark tests](https://github.com/ethereum/execution-specs/blob/tests-benchmark@v0.0.7/tests/benchmark/compute/precompile/test_alt_bn128.py) for `bn128_add` (line 19) and `bn128_pairing` (line 496) create blocks with transactions that loop `STATICCALL` to precompiles thousands of times. Each STATICCALL triggers `EnterScope()`/`ExitScope()` in the BAL tracer, but precompile calls produce empty scopes since they don't touch state (`AddBalance(0)` is filtered by `!amount.IsZero()`). The previous implementation eagerly allocated a map on every `enterScope()`, creating ~200K unnecessary heap allocations per transaction for `bn128_add` (150 gas, ~200K calls per 30M gas block). **Note:** For benchmark-only workloads that don't need BAL validation, `--bal.executionmode=sequential` can be used to skip the parallel processor entirely and avoid all tracing overhead. ## Benchmark results Precompile calls with no state changes per scope: | Calls | Before (ns/op) | After (ns/op) | Speedup | Allocs Before → After | |---|---|---|---|---| | 100 | 5,073 | 1,499 | **3.4x** | 119 → 19 | | 1,000 | 47,392 | 3,199 | **14.8x** | 1,019 → 19 | | 10,000 | 493,651 | 20,644 | **23.9x** | 10,019 → 19 | | 100,000 | 4,526,165 | 192,502 | **23.5x** | 100,019 → 19 | ## Test plan - [x] `TestLazyScopeCorrectness` — mixed workload: precompile scopes + state-changing scopes + reverted scopes - [x] `BenchmarkPrecompileScopes` — measures scope tracking overhead for 100 to 100K empty scopes - [x] All `core/vm/` tests pass - [x] All `core/types/bal/` tests pass (pre-existing failures in `TestBALEncoding` and `TestBlockAccessListValidation` unrelated) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
956f55fe83
commit
ff772bfa08
2 changed files with 167 additions and 29 deletions
|
|
@ -50,17 +50,30 @@ func newAccessListBuilder() *idxAccessListBuilder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *idxAccessListBuilder) storageRead(address common.Address, key common.Hash) {
|
// currentScope returns the current (topmost) scope, lazily allocating if needed.
|
||||||
if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok {
|
// This avoids allocating a map for scopes that never record any state changes,
|
||||||
c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{}
|
// which is critical for precompile-heavy blocks where STATICCALL creates/destroys
|
||||||
|
// thousands of empty scopes per transaction.
|
||||||
|
func (c *idxAccessListBuilder) currentScope() map[common.Address]*constructionAccountAccess {
|
||||||
|
top := len(c.accessesStack) - 1
|
||||||
|
if c.accessesStack[top] == nil {
|
||||||
|
c.accessesStack[top] = make(map[common.Address]*constructionAccountAccess)
|
||||||
}
|
}
|
||||||
acctAccesses := c.accessesStack[len(c.accessesStack)-1][address]
|
return c.accessesStack[top]
|
||||||
acctAccesses.StorageRead(key)
|
}
|
||||||
|
|
||||||
|
func (c *idxAccessListBuilder) storageRead(address common.Address, key common.Hash) {
|
||||||
|
scope := c.currentScope()
|
||||||
|
if _, ok := scope[address]; !ok {
|
||||||
|
scope[address] = &constructionAccountAccess{}
|
||||||
|
}
|
||||||
|
scope[address].StorageRead(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *idxAccessListBuilder) accountRead(address common.Address) {
|
func (c *idxAccessListBuilder) accountRead(address common.Address) {
|
||||||
if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok {
|
scope := c.currentScope()
|
||||||
c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{}
|
if _, ok := scope[address]; !ok {
|
||||||
|
scope[address] = &constructionAccountAccess{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,11 +88,11 @@ func (c *idxAccessListBuilder) storageWrite(address common.Address, key, prevVal
|
||||||
c.prestates[address].storage[key] = prevVal
|
c.prestates[address].storage[key] = prevVal
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok {
|
scope := c.currentScope()
|
||||||
c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{}
|
if _, ok := scope[address]; !ok {
|
||||||
|
scope[address] = &constructionAccountAccess{}
|
||||||
}
|
}
|
||||||
acctAccesses := c.accessesStack[len(c.accessesStack)-1][address]
|
scope[address].StorageWrite(key, prevVal, newVal)
|
||||||
acctAccesses.StorageWrite(key, prevVal, newVal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *idxAccessListBuilder) balanceChange(address common.Address, prev, cur *uint256.Int) {
|
func (c *idxAccessListBuilder) balanceChange(address common.Address, prev, cur *uint256.Int) {
|
||||||
|
|
@ -89,11 +102,11 @@ func (c *idxAccessListBuilder) balanceChange(address common.Address, prev, cur *
|
||||||
if c.prestates[address].balance == nil {
|
if c.prestates[address].balance == nil {
|
||||||
c.prestates[address].balance = prev
|
c.prestates[address].balance = prev
|
||||||
}
|
}
|
||||||
if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok {
|
scope := c.currentScope()
|
||||||
c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{}
|
if _, ok := scope[address]; !ok {
|
||||||
|
scope[address] = &constructionAccountAccess{}
|
||||||
}
|
}
|
||||||
acctAccesses := c.accessesStack[len(c.accessesStack)-1][address]
|
scope[address].BalanceChange(cur)
|
||||||
acctAccesses.BalanceChange(cur)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *idxAccessListBuilder) codeChange(address common.Address, prev, cur []byte) {
|
func (c *idxAccessListBuilder) codeChange(address common.Address, prev, cur []byte) {
|
||||||
|
|
@ -113,12 +126,11 @@ func (c *idxAccessListBuilder) codeChange(address common.Address, prev, cur []by
|
||||||
}
|
}
|
||||||
c.prestates[address].code = prev
|
c.prestates[address].code = prev
|
||||||
}
|
}
|
||||||
if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok {
|
scope := c.currentScope()
|
||||||
c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{}
|
if _, ok := scope[address]; !ok {
|
||||||
|
scope[address] = &constructionAccountAccess{}
|
||||||
}
|
}
|
||||||
acctAccesses := c.accessesStack[len(c.accessesStack)-1][address]
|
scope[address].CodeChange(cur)
|
||||||
|
|
||||||
acctAccesses.CodeChange(cur)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// selfDestruct is invoked when an account which has been created and invoked
|
// selfDestruct is invoked when an account which has been created and invoked
|
||||||
|
|
@ -127,7 +139,8 @@ func (c *idxAccessListBuilder) codeChange(address common.Address, prev, cur []by
|
||||||
// Any storage accesses/modifications performed at the contract during execution
|
// Any storage accesses/modifications performed at the contract during execution
|
||||||
// of the current call are retained in the block access list as state reads.
|
// of the current call are retained in the block access list as state reads.
|
||||||
func (c *idxAccessListBuilder) selfDestruct(address common.Address) {
|
func (c *idxAccessListBuilder) selfDestruct(address common.Address) {
|
||||||
access := c.accessesStack[len(c.accessesStack)-1][address]
|
scope := c.currentScope()
|
||||||
|
access := scope[address]
|
||||||
if len(access.storageMutations) != 0 && access.storageReads == nil {
|
if len(access.storageMutations) != 0 && access.storageReads == nil {
|
||||||
access.storageReads = make(map[common.Hash]struct{})
|
access.storageReads = make(map[common.Hash]struct{})
|
||||||
}
|
}
|
||||||
|
|
@ -144,16 +157,19 @@ func (c *idxAccessListBuilder) nonceChange(address common.Address, prev, cur uin
|
||||||
if c.prestates[address].nonce == nil {
|
if c.prestates[address].nonce == nil {
|
||||||
c.prestates[address].nonce = &prev
|
c.prestates[address].nonce = &prev
|
||||||
}
|
}
|
||||||
if _, ok := c.accessesStack[len(c.accessesStack)-1][address]; !ok {
|
scope := c.currentScope()
|
||||||
c.accessesStack[len(c.accessesStack)-1][address] = &constructionAccountAccess{}
|
if _, ok := scope[address]; !ok {
|
||||||
|
scope[address] = &constructionAccountAccess{}
|
||||||
}
|
}
|
||||||
acctAccesses := c.accessesStack[len(c.accessesStack)-1][address]
|
scope[address].NonceChange(cur)
|
||||||
acctAccesses.NonceChange(cur)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// enterScope is called after a new EVM call frame has been entered.
|
// enterScope is called after a new EVM call frame has been entered.
|
||||||
|
// The scope map is lazily allocated by currentScope() only when a state
|
||||||
|
// change occurs, avoiding heap allocations for precompile calls that
|
||||||
|
// don't touch state.
|
||||||
func (c *idxAccessListBuilder) enterScope() {
|
func (c *idxAccessListBuilder) enterScope() {
|
||||||
c.accessesStack = append(c.accessesStack, make(map[common.Address]*constructionAccountAccess))
|
c.accessesStack = append(c.accessesStack, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// exitScope is called after an EVM call scope terminates. If the call scope
|
// exitScope is called after an EVM call scope terminates. If the call scope
|
||||||
|
|
@ -162,8 +178,14 @@ func (c *idxAccessListBuilder) enterScope() {
|
||||||
// * mutated accounts/storage are added into the calling scope's access list as state accesses
|
// * mutated accounts/storage are added into the calling scope's access list as state accesses
|
||||||
func (c *idxAccessListBuilder) exitScope(evmErr bool) {
|
func (c *idxAccessListBuilder) exitScope(evmErr bool) {
|
||||||
childAccessList := c.accessesStack[len(c.accessesStack)-1]
|
childAccessList := c.accessesStack[len(c.accessesStack)-1]
|
||||||
parentAccessList := c.accessesStack[len(c.accessesStack)-2]
|
c.accessesStack = c.accessesStack[:len(c.accessesStack)-1]
|
||||||
|
|
||||||
|
// If no state was accessed in this scope, nothing to merge.
|
||||||
|
if childAccessList == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAccessList := c.currentScope()
|
||||||
for addr, childAccess := range childAccessList {
|
for addr, childAccess := range childAccessList {
|
||||||
if _, ok := parentAccessList[addr]; ok {
|
if _, ok := parentAccessList[addr]; ok {
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -177,8 +199,6 @@ func (c *idxAccessListBuilder) exitScope(evmErr bool) {
|
||||||
parentAccessList[addr].Merge(childAccess)
|
parentAccessList[addr].Merge(childAccess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.accessesStack = c.accessesStack[:len(c.accessesStack)-1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalise returns the net state mutations at the access list index as well as
|
// finalise returns the net state mutations at the access list index as well as
|
||||||
|
|
@ -188,6 +208,11 @@ func (a *idxAccessListBuilder) finalise() (*StateDiff, StateAccesses) {
|
||||||
diff := &StateDiff{make(map[common.Address]*AccountMutations)}
|
diff := &StateDiff{make(map[common.Address]*AccountMutations)}
|
||||||
stateAccesses := make(StateAccesses)
|
stateAccesses := make(StateAccesses)
|
||||||
|
|
||||||
|
// Root scope may be nil if no state changes occurred at all.
|
||||||
|
if a.accessesStack[0] == nil {
|
||||||
|
return diff, stateAccesses
|
||||||
|
}
|
||||||
|
|
||||||
for addr, access := range a.accessesStack[0] {
|
for addr, access := range a.accessesStack[0] {
|
||||||
// remove any reported mutations from the access list with no net difference vs the index prestate value
|
// remove any reported mutations from the access list with no net difference vs the index prestate value
|
||||||
if access.nonce != nil && *a.prestates[addr].nonce == *access.nonce {
|
if access.nonce != nil && *a.prestates[addr].nonce == *access.nonce {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package bal
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -257,5 +258,117 @@ func TestBlockAccessListValidation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLazyScopeCorrectness verifies that lazy scope allocation produces
|
||||||
|
// identical results to the previous eager allocation for mixed workloads:
|
||||||
|
// precompile calls (empty scopes) interspersed with state-changing calls.
|
||||||
|
func TestLazyScopeCorrectness(t *testing.T) {
|
||||||
|
builder := newAccessListBuilder()
|
||||||
|
sender := common.HexToAddress("0x1234")
|
||||||
|
contract := common.HexToAddress("0x5678")
|
||||||
|
precompile := common.HexToAddress("0x06")
|
||||||
|
|
||||||
|
// Tx-level: sender balance/nonce
|
||||||
|
builder.balanceChange(sender, uint256.NewInt(1000), uint256.NewInt(900))
|
||||||
|
builder.nonceChange(sender, 0, 1)
|
||||||
|
|
||||||
|
// Enter contract scope
|
||||||
|
builder.enterScope()
|
||||||
|
builder.storageRead(contract, common.HexToHash("0x01"))
|
||||||
|
|
||||||
|
// Precompile STATICCALL (empty scope)
|
||||||
|
builder.enterScope()
|
||||||
|
builder.exitScope(false)
|
||||||
|
|
||||||
|
// Another precompile call
|
||||||
|
builder.enterScope()
|
||||||
|
builder.exitScope(false)
|
||||||
|
|
||||||
|
// Contract writes storage
|
||||||
|
builder.storageWrite(contract, common.HexToHash("0x02"), common.Hash{}, common.HexToHash("0xff"))
|
||||||
|
|
||||||
|
// Precompile that reverts (still empty scope, reverted)
|
||||||
|
builder.enterScope()
|
||||||
|
builder.exitScope(true)
|
||||||
|
|
||||||
|
// Nested call to another contract
|
||||||
|
builder.enterScope()
|
||||||
|
builder.balanceChange(precompile, uint256.NewInt(0), uint256.NewInt(100))
|
||||||
|
builder.exitScope(false)
|
||||||
|
|
||||||
|
// Exit contract scope
|
||||||
|
builder.exitScope(false)
|
||||||
|
|
||||||
|
diff, accesses := builder.finalise()
|
||||||
|
|
||||||
|
// Verify sender mutations
|
||||||
|
senderMut, ok := diff.Mutations[sender]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("sender not in mutations")
|
||||||
|
}
|
||||||
|
if senderMut.Balance == nil || !senderMut.Balance.Eq(uint256.NewInt(900)) {
|
||||||
|
t.Fatalf("sender balance mismatch: got %v", senderMut.Balance)
|
||||||
|
}
|
||||||
|
if senderMut.Nonce == nil || *senderMut.Nonce != 1 {
|
||||||
|
t.Fatalf("sender nonce mismatch: got %v", senderMut.Nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify contract mutations (storage write)
|
||||||
|
contractMut, ok := diff.Mutations[contract]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("contract not in mutations")
|
||||||
|
}
|
||||||
|
if contractMut.StorageWrites == nil {
|
||||||
|
t.Fatal("contract has no storage writes")
|
||||||
|
}
|
||||||
|
if contractMut.StorageWrites[common.HexToHash("0x02")] != common.HexToHash("0xff") {
|
||||||
|
t.Fatal("contract storage write mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify precompile balance change
|
||||||
|
precompileMut, ok := diff.Mutations[precompile]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("precompile not in mutations")
|
||||||
|
}
|
||||||
|
if precompileMut.Balance == nil || !precompileMut.Balance.Eq(uint256.NewInt(100)) {
|
||||||
|
t.Fatalf("precompile balance mismatch: got %v", precompileMut.Balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify contract storage read is in accesses
|
||||||
|
contractAccesses, ok := accesses[contract]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("contract not in accesses")
|
||||||
|
}
|
||||||
|
if _, ok := contractAccesses[common.HexToHash("0x01")]; !ok {
|
||||||
|
t.Fatal("contract storage read not in accesses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkPrecompileScopes simulates a precompile-heavy transaction where
|
||||||
|
// STATICCALL is invoked thousands of times against a precompile (e.g. bn128_add).
|
||||||
|
// Each call creates a scope (EnterScope) that records no state changes (precompiles
|
||||||
|
// don't touch state), then exits (ExitScope). This benchmark measures the overhead
|
||||||
|
// of scope tracking for such workloads.
|
||||||
|
func BenchmarkPrecompileScopes(b *testing.B) {
|
||||||
|
for _, numCalls := range []int{100, 1000, 10000, 100000} {
|
||||||
|
b.Run(fmt.Sprintf("calls=%d", numCalls), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
builder := newAccessListBuilder()
|
||||||
|
// Simulate a transaction: sender balance/nonce change at depth 0,
|
||||||
|
// then thousands of precompile STATICCALL scopes that touch no state.
|
||||||
|
sender := common.HexToAddress("0x1234")
|
||||||
|
builder.balanceChange(sender, uint256.NewInt(1000), uint256.NewInt(900))
|
||||||
|
builder.nonceChange(sender, 0, 1)
|
||||||
|
|
||||||
|
for j := 0; j < numCalls; j++ {
|
||||||
|
builder.enterScope()
|
||||||
|
// Precompile call: no state hooks fire
|
||||||
|
builder.exitScope(false)
|
||||||
|
}
|
||||||
|
builder.finalise()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BALReader test ideas
|
// BALReader test ideas
|
||||||
// * BAL which doesn't have any pre-tx system contracts should return an empty state diff at idx 0
|
// * BAL which doesn't have any pre-tx system contracts should return an empty state diff at idx 0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue