本文分析了六种区块访问列表(BAL)设计对以太坊Geth性能的影响,指出I/O操作是执行瓶颈(占比约70%)。通过在Geth中实现并测试不同类型的BAL,实验证明该方案能显著减少60%至70%的区块处理时间,并探讨了并行执行与Trie节点获取的未来优化方向。
由 Po @ QuarkChain 编写
Map[address/slot, account/storage value]List[tx_index, modified account and storage values]我们探索了六种 BAL 设计变体(为简洁起见,不包括合约代码):
类型-5 和类型-6 之间的主要区别在于 BAL 验证:
开销表格(不包括合约代码大小),2000 个区块:22347001-22349000

虽然这里没有包含合约代码,但对于乐观或完全并行执行,只需要提供新创建的合约代码。对于现有合约,由于现有代码的并行获取速度足够快,因此只需要
codeHash。
一个关键的观察结果是,类型-3 和类型-6 BAL 的平均区块大小开销小于类型-2 (EIP-7928),因为大多数账户和存储写入可能不会被同一区块内的后续交易访问。这使得类型-6 成为一个极具吸引力的 BAL 选项,在开销和性能之间提供了更好的平衡。
由于类型-1、类型-3 和类型-4 的空间效率,以及在 Geth 中为类型-2、类型-5 和类型-6 启用完全并行执行所涉及的复杂性,我们在最新的 Geth 中仅实现了类型-1、类型-3 和类型-4。
BAL 数据结构:
var AllBlockAccessLists = map[uint64]types.AccessList{}
在 processBlock 期间,所有 BAL 值在交易执行前被预取。
func (p *StateProcessor) Process(block *types.Block, statedb *state.StateDB, cfg vm.Config) (*ProcessResult, error) {
...
statedb.PrefetchAccessList(block.Number().Uint64())
...
}
预取的状态被存储到 StateDB.stateObjects[addr].originStorage 中。
我们首先并行获取账户,然后并行获取存储值,从而消除了后续执行中几乎所有的 I/O 读取(加载合约代码除外)。( 注意:这是一个初始版本;尚未应用并行执行以及并行账户和存储获取优化。)
并行预取账户和插槽值,并设置插槽值的代码片段:
type StateObject struct {
obj *stateObject
keys []common.Hash
}
type StorageKV struct {
obj *stateObject
key *common.Hash
val *common.Hash
}
tp := NewThreadPool(25)
tp.Start()
lenAccts := 0
lenMaxSlots := 0
accts := make(chan StateObject, len(bals))
accountReadStart := time.Now()
for _, bal := range bals {
addr := bal.Address
lenAccts++
keys := bal.StorageKeys
lenMaxSlots += len(keys)
tp.AddTask(func() {
// s.reader.Account 因为 keccakhash 不是线程安全的,所以改为 AccountBAL
acct, err := s.reader.AccountBAL(addr)
if err != nil {
log.Error("fail to fetch account:", addr)
}
obj := newObject(s, addr, acct)
accts <- StateObject{obj, keys}
})
}
acctsArr := []StateObject{}
// 在 stateDB 中设置预取的账户
for range lenAccts {
state := <-accts
acctsArr = append(acctsArr, state)
// 必须先设置它,以避免稍后在 storageBAL 中读取账户
s.setStateObject(state.obj)
}
close(accts)
s.AccountReads += time.Since(accountReadStart)
storages := make(chan *StorageKV, lenMaxSlots)
lenSlots := 0
storageReadStart := time.Now()
for _, state := range acctsArr {
obj := state.obj
keys := state.keys
addr := obj.Address()
if obj.origin == nil {
continue
}
lenSlots += len(keys)
acct := obj.origin
tr, err := trie.NewStateTrie(trie.StorageTrieID(s.originalRoot, obj.addrHash, acct.Root), s.db.TrieDB())
if err != nil {
log.Error("fail to create trie for:", addr)
panic("fail to create trie for:")
}
for _, key := range keys {
key := key
tp.AddTask(func() {
val, err := obj.db.reader.StorageBAL(addr, key, tr)
if err != nil {
log.Error("fail to fetch storage:", addr, key)
}
kv := &StorageKV{obj, &key, &val}
storages <- kv
})
}
}
tp.Stop()
s.StorageReads += time.Since(storageReadStart)
// 在 stateDB 中设置预取的存储值
for range lenSlots {
kv := <-storages
kv.obj.originStorage[*kv.key] = *kv.val
s.setStateObject(kv.obj)
}
类型-3 的实现与类型-2 大致相似,关键区别在于插槽值直接在 stateDB 中设置,而无需从数据库中获取。
BAL 数据结构:
type StorageKV struct {
Key common.Hash `json:"key"`
Val common.Hash `json:"val"`
}
type AccessListKV struct {
Address common.Address `json:"address" gencodec:"required"`
Nonce uint64 `json:"nonce"`
Balance *uint256.Int `json:"balance"`
Root common.Hash `json:"root"`
CodeHash []byte `json:"codeHash"`
StorageKV []StorageKV `json:"storageKeys" gencodec:"required"`
}
在 processBlock 期间,所有 BAL 值在交易执行前被预取。
预取的状态也存储到 StateDB.stateObjects[addr].originStorage 中。
我们首先并行获取账户,然后直接从 BAL 设置相应的存储值。
并行预取账户和设置存储的代码片段:
tp := NewThreadPool(25)
tp.Start()
lenAccts := 0
accts := make(chan *stateObject, len(bals))
for _, bal := range bals {
addr := bal.Address
lenAccts++
kvs := bal.StorageKV
tp.AddTask(func() {
acct, err := s.reader.AccountBAL(addr)
if err != nil {
log.Error("fail to fetch account:", addr)
}
obj := newObject(s, addr, acct)
if acct != nil {
for _, kv := range kvs {
obj.originStorage[kv.Key] = kv.Val
}
}
accts <- obj
})
}
for range lenAccts {
obj := <-accts
s.setStateObject(obj)
}
类型-4 的实现紧随类型-3,但账户值和插槽值都直接在 stateDB 中设置,无需任何获取操作。BAL 数据结构与类型-3 保持一致。
for _, bal := range bals {
addr := bal.Address
kvs := bal.StorageKV
if bal.Balance == nil {
bal.Balance = ZeroBalance
bal.Root = types.EmptyRootHash
bal.CodeHash = types.EmptyCodeHash.Bytes()
}
acct := types.StateAccount{
Nonce: bal.Nonce,
Balance: bal.Balance,
CodeHash: bal.CodeHash,
Root: bal.Root,
}
obj := newObject(s, addr, &acct)
for _, kv := range kvs {
obj.originStorage[kv.Key] = kv.Val
}
s.setStateObject(obj)
}
state.scheme=pathprestateTracer 的 debug_traceTransaction 收集 2000 个区块([22347001, 22349000])的 BAL,以 JSON 格式存储,并在 Geth 启动时加载。export 和 import 命令。geth export --datadir ./geth_full/snap/ dump.dat 22347001 22349000
geth --pprof.cpuprofile cpu1.prof --datadir ./geth_snap import --nocompaction true dump.dat
时间成本细分涵盖了 I/O、执行、验证和提交阶段:

主要发现:
我们还对 CPU 时间(不包括 I/O)进行了性能分析,前 10 个最耗时的函数如下所示:

详细的调用栈附在这里:
观察结果:
keccak, atomic.(*Int32).Add(RLock))maps.ctrlGroup.matchH2)runtime.cgocall)
[1] EIP-7928: Block-Level Access Lists
[2] Code for Reproducing BAL Overhead
[3] Type-1, Type-3 and Type-4 BAL Implementations in Geth
[5] ESP Academic Grant 2023: EthStorage/Quarkchain Earlier BAL Submission
- 原文链接: hackmd.io/X4Z4h-EQRPSiQF...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!