Ethrex 团队通过系统性的性能分析、针对性改进和架构变更,实现了区块执行吞吐量 20 倍的提升。关键优化策略包括 EVM 核心优化(如新的内存模型、opcode 查找表、inline 常用 opcodes)、通用哈希优化(如替换为 fxhash)、数据存储和状态树优化(如延迟哈希计算、并发 trie 更新),以及执行流水线优化(如 execution-based prewarming)。
在过去的几个月里,ethrex 团队 一直坚持不懈地专注于性能优化。
通过系统的性能分析、有针对性的改进和架构变更,我们在区块执行吞吐量方面实现了 20 倍的提升。
性能工作的真实案例非常有价值(例如,我们非常喜欢 Nicholas Nethercote 关于加速 rust 编译器的系列文章),因此我们认为深入探讨这是如何实现的非常有价值。
如果你查询存储库的 PR 并按“性能”标签进行筛选,你会发现超过一百个 (!) pull request,仅去年 11 月以来就有 34 个。
如果你查询任何已合并或关闭的 PR,你会发现更多被丢弃的,勇敢的尝试没有在与现实的对抗中幸存下来。
似乎再怎么强调这种改进的基础是经验性的,测量、假设和通过更多观察来验证都不为过。
制定某种修改会带来改进的假设(或验证代理提出的假设)确实需要熟悉代码库的上下文知识。
正如我们经常重复的那样,拥有一个更简单的代码库极大地促进了这一点。
对于 ethrex 而言,为了更好地理解以下内容,我们强烈建议你阅读项目文档。
在深入探讨究竟是什么促成了如此大的改进之前,让我们先讨论一下一般的性能工程工作。
我们究竟想要优化什么?
最初,正如帖子标题所暗示的那样,我们测量 MGas/秒 是因为…… 好吧,因为许多其他人用它来衡量 Ethereum 客户端的执行性能,如果你想比较,你需要比较苹果和苹果。
在处理交易时,合约代码执行期间,每计算一次花费多少 gas 之间存在关系,因此每秒 gas 越多,速度就越快。但是,这并不像测量_时间_那么直接,处理一个交易需要多长时间,它涉及到所有这些?使用代理指标有时会导致感觉像用 cue sticks 吃拉面的情况。你仍然无法摆脱必须尽可能精确地指定上下文中所有内容的情况:硬件、操作系统、负载等。
测量很难。所以你要自动化它。
Ethrex 的基准测试由 CI 在每次更改时运行,报告会定期发布在 benchmarks.ethrex.xyz/。
与其他客户端的比较可以在 EthPandas 出色的 实验室页面 中找到:

你在那里看到的是一个表格,比较了 engine_newPayload 方法的延迟(以毫秒为单位),按执行客户端和版本。
engine_newPayload 是核心引擎 API 方法之一 - 它是共识层告诉执行层验证和执行新区块的主要方式。
作为回应,执行客户端:
左边的三列列出了平均值,以及第 50 和 95 个百分位数。
在这里,我们想指出另一个陷阱:正如你可能听说过的,有谎言、该死的谎言和统计数据。就像我们说的那样,测量很难,而_如何_测量很重要。
什么是 p50?许多软件工程师将其等同于平均值。如果你测量你 API 的端点延迟,p50 就是“普通用户获得的”。但从数学上讲,它们并不完全相同。在许多情况下,它们非常相似,但正确的定义是 p50 是中位数,是观察值的一半低于它,一半高于它的值。
所有这些都假设你正在谈论“原始”数据,即基本数据集。
在测量实时系统或时间序列数据时,许多实现有时会使用数据的滑动窗口。如果你计算此处理数据的指标(例如 p50),你将获得或删除 artifacts。你可以“平滑”你的数据,删除异常值或峰值。
因此,重申一下,如果你想推断系统的行为,那么如何测量很重要。但是,当你想与其他人进行比较时,有时你需要妥协并以相同的方式进行测量。
那么,最重要的变化是什么?
我们可以或多或少地将它们按以下内容进行分组:
EVM 性能直接影响交易执行速度,因为这是虚拟机的“解释开销”。
在 EVM 中,我们确定了两个可以改进的领域:主解释循环和合约代码执行期间的内存访问模式。
Gas 基准测试揭示了内存操作中的显着性能瓶颈,尤其是在 mstore 指令中。我们意识到我们需要实现一种新的内存模型 ( #3564),该模型具有优化的访问模式和在安全的情况下进行的不安全代码优化。
通过此更改,基准测试显示基于 opcode 的计时提高了 23%,端到端提高了 12%。在添加不安全代码后,我们在 mstore 改进的基础上看到了 30% 的改进,并且其他 opcode 的整体性能也得到了提高。
在交易处理期间,堆栈对象分配消耗了大约 8% 的执行时间。我们实现了 ( #5179) 块执行中跨交易的堆栈池重用。在块级别创建一个共享池,并在整个过程中重用,使用 std::mem::swap() 与 VM 实例交换池。
在执行非常常见的 CALLDATACOPY/CODECOPY/EXTCODECOPY EVM opcode 期间也会发生不必要的堆分配,因此在复制操作中优化了内存处理 ( #5810)。总的来说,这减少了 mainnet 中的块处理时间 4-6ms。
VM 核心循环的原始实现使用 match 语句进行 opcode 调度,这对于热执行路径来说不是最佳的。Samply 显示了在 execute_opcode 函数上花费了相当多的时间,因此我们替换了 ( #3669) 基于 match 的 opcode 调度,使用函数指针查找表进行 opcode 解析。
这在 MSTORE 操作中获得了 24% 的改进(超出内存模型收益),并且包括所有测试的 gas 基准测试的改进。
开销可能来自决定执行哪个 opcode,也可能来自调用实现所述 opcode 的代码。这种频繁执行的 opcode 的函数调用开销在紧密循环中是可以衡量的,因此我们将最常用的 opcode(DUP、PUSH、SWAP、JUMP)直接内联到执行循环中 ( #5761),从而使这些操作的速度提高了 30-40%。这转化为 mainnet 执行中约 20% 的改进。

在分析并查看 flamegraph 时,我们注意到哈希函数经常出现。当哈希用于协议中的协调或唯一标识共享数据库中的数据时,更改哈希函数是 delicate的。但是,当它在内部使用时,哈希函数通常可以切换到更快的函数,特别是如果存在关于被哈希数据的上下文信息(例如,在编译器中,专门用于短字符串的哈希函数非常有用)。
我们在整个代码库中用 fxhash 替换了默认的哈希函数:在 diff 层哈希映射中 ( #5032),在对等表的 discv4 哈希集中 ( #5688),以及访问列表中 ( #5824)。
另一方面,SHA3/Keccak 哈希属于另一类,它是一个关键的密码学原语,必须不惜一切代价与其他实现兼容。
纯 Rust SHA3/Keccak 实现有优化空间,并且考虑到其使用频率,我们尝试用汇编语言编写的版本替换它。这不是内部手写的,而是来源于著名的 CRYPTOGAMS,Andy Polyakov 的项目用于开发高速加密原语。
结果很有希望,并且包括 x86 和 ARMv8 的版本,并为不受支持的平台提供回退。
优化堆栈树的“叶子”通常被认为是唾手可得的果实。一般来说,如果函数被经常调用,它们将占据执行时间的大部分,并且改进它们是值得的。
但是,一些改进更多地与如何移动数据有关,这些更改需要更多的“架构”更改。
Patricia Merkle Trie 每次修改都会重新计算受影响节点的哈希,导致计算量大的“强迫哈希”,因此我们重构了 ( #2687) 它以延迟哈希计算,直到实际需要时才进行。直接节点哈希引用被替换为缓存的 NodeHash 值(未修改的节点)或实际节点对象(已更改的节点),从而将哈希延迟到提交时间。
这是早期优化中最具影响力的优化之一,使 trie 插入时间几乎减半。
我们第一次实现 mainnet 同步时,它可以工作,但速度很慢。
数据库使用了一种不可变的、基于哈希的方法,导致它不必要地增长。第一个更改是使其基于路径,这有所改进,但更重要的是导致了下一个更改,即在顶部添加一个扁平的键值层,以避免遍历树来读取值。
在改进了基本数据结构之后,我们开始寻找并发机会。也许有些事情可以在后台完成?
事实证明,apply_updates 函数在块生产期间同步执行,创建阻塞操作,从而延迟执行。
执行后的 trie 更新被移动到 ( #4989) 后台线程,该线程通过通道接收消息以应用新的 trie 更新并执行两项操作:首先,它更新内存中的 diff 层并通知发送消息的进程(即块生产线程),以便它可以继续进行块执行。其次,它执行将最底层的 diff 层持久化到磁盘的逻辑。为此,我们首先通过消息暂停负责生成快照(即 FlatKeyValue)的正在运行的线程,然后将 diff 层持久化到磁盘,然后再次通知线程继续生成快照。
这减少了块执行延迟,并通过更好地重叠 I/O 和计算来提高了块生产吞吐量。
块执行和 merkle 树更新是顺序操作,导致 CPU 资源未得到充分利用,因此对块处理进行了重组 ( #5084),以重叠 EVM 执行和 merkle 树更新,从而允许两者并行进行。
这种 merkleization 和执行的流水线化使 ethrex-trie insert 1k 基准测试提高了约 47%。
在寻找更多并行工作机会时,我们看到 merkleization 是单线程的,没有利用可用的 CPU 核心。merkleization 本身可以并行吗?
我们采用了一种类似于 Gravity 的自上而下分片方法,通过扩展之前的管道来分片更新并在末尾连接更新后的 tries 来实现 16 路并行 merkleization ( #5377)。每个工作线程处理哈希地址以特定 nibble (0-F) 开头的帐户。结合流水线化的 VM 执行和 merkleization,这会将 merkleization 从关键路径中删除。
我们最大的胜利之一来自最近对缓存的改进,通过向预热缓存添加推测性的乱序执行,又名 基于执行的预热 ( #5906)。虽然交易必须按顺序执行(每个交易都依赖于之前的状态),但我们可以并行地推测性地执行它们,以预测将读取哪些帐户和存储槽,并提前缓存这些值。一旦缓存预热,实际的顺序执行就会继续进行,从而减少实际执行期间的 I/O。
在对人为大的 (gigagas) 块进行基准测试时,这显示出明显的 (724->968 MGas/秒,1.64 秒->1.23 秒的总延迟) 改进,并且在当前 mainnet 中提高了约 10%。
后续更改 #5999 受 reth 方法的 启发,在预热 worker 之间添加了一个共享缓存,因此多个 worker 不会多次获取相同的状态。这使块执行吞吐量提高了 20% 到 25%。在其中一台服务器上,我们看到吞吐量从 514 提高到 637 MGas/秒,延迟从 64 毫秒降低到 57 毫秒(提高了 12%)。
这是最新优化中最具影响力的优化之一,它通过并行化实现了显着的收益,而无需更改执行语义。
这些优化代表了几个月的性能分析、基准测试和迭代改进,从而使 ethrex 能够更快地处理块、使用更少的内存并更好地利用现代 CPU 功能,例如 SIMD 和多核并行性。
这项工作远未完成,甚至更多的改进已经在进行中。
从 20 MGas/秒 到 400 MGas/秒 的旅程表明,当你系统地进行性能分析并有条不紊地解决瓶颈时,总有优化的空间。
所有基准测试均在我们的性能测试基础设施上运行。单个结果可能因硬件和工作负载特征而异。
- 原文链接: blog.lambdaclass.com/eng...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!