Monad JIT 编译优化

  • keonehd
  • 发布于 9小时前
  • 阅读 15

本文详细介绍了 Monad 如何通过 JIT 编译技术优化 EVM 执行性能。Monad 采用双路径架构,根据合约调用频率自适应地将 EVM 字节码编译为原生 x86-64 代码,并利用寄存器分配、常量折叠和Gas费检查批处理等手段,在保持 EVM 等效性的同时大幅降低执行开销。

Image

EVM 是一种执行智能合约字节码的栈式虚拟机。在标准的以太坊客户端中,解释器逐个处理字节码指令:获取下一条指令、检查 Gas、检查栈边界、通过函数表分发、执行,然后重复。这些逐条指令的开销会不断累积,且相同的合约代码在每次调用时都会以完全相同的方式被重新解释。

Monad 包含一个优化的解释器和一个 JIT (即时) 编译器,能够将 EVM 字节码转换为原生的 x86-64 机器码。该编译器消除了逐条指令的开销,并实现了在解释器中无法进行的优化,同时保持了精确的 EVM 语义。

双路径架构

Monad 维护了合约代码的两种表示形式:

  • Intercode:解释器使用的优化中间表示。这是所有合约的备选方案,且始终可用。
  • Nativecode:编译后的 x86-64 机器码,由 JIT 编译器 为热点合约生成。一旦可用,它将替代该合约的解释器。

这两种表示形式共同缓存在 Varcode 结构中(cache),并以合约的代码哈希作为键。Varcode 还会跟踪解释执行下的累计 Gas 消耗,以此决定何时进行编译。

自适应编译

并非所有合约都值得编译。一个部署后从未被调用的合约无法从编译中获益,而一个每区块被调用数千次的重度使用 DEX 路由则能获得巨大收益。

Monad 使用 Gas 加权的自适应编译。每当通过解释器执行合约时,消耗的 Gas 都会累加到 Varcode 的 原子计数器 中。当累计 Gas 超过 与字节码大小成正比的阈值(约为字节码长度的 32 倍)时,合约将被提交进行后台编译。

这意味着:

  • 合约按影响力顺序编译:消耗执行 Gas 最多的合约优先编译。
  • 简单的小型合约比复杂的大型合约具有更低的绝对编译阈值。
  • 很少被调用的合约保持在解释器中,避免浪费编译资源。

编译在专用的后台线程中 异步 进行。一个 并发队列 保存待处理的编译任务,编译器线程逐一处理。在合约编译期间,它继续在解释器上执行。编译完成后,原生代码被插入 Varcode 缓存,后续调用将立即使用它。

Intercode:优化的解释器表示

在进行 JIT 编译之前,原始 EVM 字节码会被转换为 Intercode —— 一种解释器可以更高效执行的预处理形式。

其中一项转换是代码填充:字节码在实际指令 之前扩展 30 字节,之后扩展 33 字节。这种填充可以防止解释器的前瞻逻辑在扫描代码末尾附近的多字节指令(如 PUSH32)时出现越界读取。

Intercode 还会预计算 JUMPDEST 映射(一个 vector<bool>),用于标记有效的跳转目标。当解释器或编译器遇到 JUMP 或 JUMPI 时,它可以在 O(1) 时间内根据此映射验证目标。JUMPDEST 扫描会仔细跳过 PUSH 指令后的立即数数据字节(例如,PUSH32 后的 32 字节不能包含有效的 JUMPDEST)。

编译流水线

编译器通过以下几个阶段将 EVM 字节码翻译为 x86-64 机器码:

1. 基本块分析

字节码被划分为基本块:具有单一入口点和单一出口点的指令序列。基本块结束于控制流指令(JUMP, JUMPI, RETURN, REVERT, STOP, SELFDESTRUCT 或无效操作码),并开始于跳转目标(JUMPDEST)。

每个基本块都有一个 终止符 类型:

终止符 描述
FallThrough 块结束并进入下一个块
Jump 跳转到动态目标的无条件跳转
JumpI 条件分支 (JUMPI)
Return RETURN 指令
Stop STOP 指令
Revert REVERT 指令
SelfDestruct SELFDESTRUCT 指令
InvalidInstruction 非法操作码;始终回滚

基本块分析还会计算每个块的 栈增量 (stack delta):即栈深度的净变化 (delta),以及块执行期间达到的最小栈深度 (min_delta)和最大栈深度 (max_delta)。这些增量允许编译器验证块是否会导致栈下溢,并了解每个块边界处的精确栈布局。

2. Gas 检查批处理

在解释器中,每个操作码之前都会检查 Gas。编译器在每个基本块的开头执行单次 Gas 检查,涵盖该块内所有指令的总静态 Gas 成本。这用每个基本块一次 Gas 检查取代了 N 次(每个操作码一次)检查。

编译器使用 1,000 Gas 的阈值 来衡量是否值得发出 Gas 检查。静态 Gas 少于此值的块可能会将其检查推迟到下一个块,从而减少紧凑内循环中的分支开销。

具有动态 Gas 成本的指令(如 SLOAD、CALL 或内存扩展)在运行时仍需要单独的 Gas 检查,但静态部分已被批处理。发射器区分 Static work 块(总 Gas 静态已知)和 Unbounded 块(包含动态 Gas 指令,必须更仔细地检查)。

3. 常量折叠

常量操作序列在编译时进行求值。例如:

PUSH1 0x02
PUSH1 0x03
ADD

编译器识别出结果是一个常量 (5),并用单个字面量值替换这三条指令序列。Gas 计费被保留——编译后的代码仍会为原始的三条指令收取正确的 Gas——但消除了 CPU 的实际计算工作。

虚拟栈 不仅将值表示为寄存器或内存位置,还表示为字面量(编译时已知的常量)。两个字面量的算术运算会产生另一个字面量,常量会尽可能地传播,直到必须将其具体化到寄存器中。

4. 寄存器分配

EVM 是栈机,但 x86-64 拥有 16 个通用寄存器和 16 个 AVX/SSE 向量寄存器。编译器通过 虚拟栈 将 EVM 栈槽映射到机器位置——这是一个跟踪每个栈元素当前位置的内部数据结构。

每个 栈元素 (StackElem) 可以处于以下四种状态之一:

  • Literal:编译时常量,不需要寄存器。
  • AvxReg:AVX 寄存器(ymm0–ymm15 或 zmm0–zmm15)。
  • GeneralReg:x86-64 通用寄存器(rax, rcx, rdx)。
  • StackOffset:溢出到运行时栈的固定偏移处。

寄存器分配器维护:

  • 16 个 AVX 寄存器,用于 256 位值(EVM 的原生字长)。AVX2/AVX-512 指令可以在单条指令中执行 256 位操作,直接匹配 EVM 的字宽。
  • 3 个通用寄存器 (rax, rcx, rdx),用于参与地址计算、控制流或自然映射到标量 x86 指令的操作。
  • 保留两个 专用寄存器:rbx 持有指向执行上下文的指针,rbp 持有指向内存中 EVM 栈的指针。

当活跃值的数量超过可用寄存器时,分配器会将值溢出到运行时栈的固定偏移处。栈帧布局为函数参数保留了 6 个槽位,以及额外的临时存储空间。

由于虚拟栈跟踪每个操作数的精确位置,指令在发射时会根据其操作数位置进行特化。例如,两个操作数都在 AVX 寄存器中的 AND 操作会发射一条 vpand (ymm, ymm, ymm) 指令,直接在 256 位寄存器上操作,无需数据移动。如果其中一个操作数已溢出,发射器则回退到内存源形式。这种位置感知发射正是虚拟栈的价值所在:相同的 EVM 操作码可以根据其输入当前的存储位置产生不同(更廉价)的原生序列。

虚拟栈还实现了 延迟比较 优化:条件分支 (JUMPI) 检查布尔条件。如果该条件刚刚由比较指令计算得出,编译器可以避免将 0/1 结果具体化到寄存器中,而是直接根据 CPU 的标志寄存器发射条件跳转。

5. 原生代码发射

编译器直接发射 x86-64 机器码,使用 asmjit 库作为汇编器后端。这是一个深思熟虑的架构选择:Monad 不使用 LLVM 或任何其他通用编译器框架。直接发射提供了:

  • 对生成代码的最大控制:每条指令都专门针对 EVM 执行模式进行选择。
  • 极低的编译延迟:没有中间优化阶段,没有链接器调用,没有重定位处理。
  • 可预测的性能:生成的代码与输入紧密相关,不会受到优化器启发式算法的干扰。

权衡之处在于编译器必须实现自己的优化,而不是依赖框架,但 EVM 字节码的优化面足够窄,这种做法是切实可行的。

发射器使用自定义的 EmitErrorHandler 捕获 asmjit 错误并将其转换为 类型化错误代码(NoError, Unexpected, SizeOutOfBound),允许编译流水线干净地处理失败并回退到解释器。

6. 代码大小限制

编译器强制执行 最大编译代码大小,即原始字节码长度的 32 倍。此限制可防止生成代码中相对跳转寻址的整数溢出:如果原生代码中的条件分支使用 32 位有符号偏移量来到达另一个块,且每个字节码字节最多产生 32 字节的原生代码,则偏移量永远不会超过可寻址范围。

如果编译将超过此限制,则会以 SizeOutOfBound 失败,合约将永久回退到解释器。

7. 只读数据段

编译器为生成代码引用的字面量(256 位常量、地址等)维护一个 只读数据段 (RoData)。常量会被去重:如果同一个 256 位值在字节码中多次出现,它在 RoData 段中仅存储一次,并从生成代码中的多个点引用。

这对于在多个上下文中使用相同常量的合约非常重要——例如,在多个操作中使用特定掩码的合约将从单个内存位置加载该掩码,而不是在指令流中重复嵌入。

解释器

解释器是备选执行路径,其本身必须具备高性能。它使用了几项关键优化:

  • 计算跳转分发 (Computed-goto dispatch):解释器不使用 switch 语句,而是使用计算跳转目标的 分发表 (instruction_table) —— 每个操作码对应一个标签地址。在每一步,解释器加载下一个操作码,索引到表中,并直接跳转到处理程序。这比 switch 快,因为它避免了对分支预测不利的间接分支或范围检查。
  • 汇编蹦床 (Assembly trampoline):解释器的内循环用汇编实现(或带有汇编提示),以精确控制寄存器使用,确保指令指针和栈指针在迭代过程中保持在寄存器中,并避免编译器生成的序言 (prologue) 开销。

共享基础设施:解释器和编译后的代码共享相同的内存池、宿主接口、运行时函数表和状态基础设施。在生命周期内从解释执行过渡到编译执行(随着 Gas 累积)的合约是透明的——调用者感知不到差异。

代码缓存

编译后的代码存储在以代码哈希为键的 基于权重的 LRU 缓存 中。缓存的 默认容量为 4 GB,并在 75% 容量处设有“预热”阈值

每个条目的 权重与编译后的代码大小(以 KB 为单位)成正比,外加每个条目固定的 3 KB 开销 以计算元数据。当缓存超过容量时,最久未使用的条目将被驱逐。预热阈值用于确定缓存是否有足够的条目以发挥效力——在缓存预热之前,编译器可能会更积极地优先处理编译。

缓存通过 Varcode 抽象在所有线程间共享:并发哈希映射允许多个 fiber 同时查找编译后的代码而无竞争。

EVM 版本支持

Monad 的执行引擎支持多个 EVM 版本(Frontier, Homestead, Istanbul, Berlin, London, Shanghai, Cancun, Prague 等)。编译器通过 traits 类型进行参数化,该类型编码了哪些操作码可用、适用哪些 Gas 计划以及各版本之间的行为差异。

使用了两种 trait 实现策略:

  • 显式 traits:每个版本实例化一次模板,为每个版本生成独立的原生代码路径。这是 JIT 编译器的主要策略,因为在每次编译调用中支付运行时版本检查的开销是不划算的。
  • 切换 traits:一种运行时分发策略,用于模板实例化开销过高的上下文。

当添加新版本时,编译器和解释器会同步更新,确保编译路径和解释路径产生完全相同的语义。

仍使用解释器的情况

解释器处理几种不适用编译或尚未准备好编译的情况:

  • 冷合约:尚未累积足够 Gas 使用量以触发编译的合约。
  • CREATE/CREATE2:合约创建始终通过解释器运行,因为字节码是全新的且可能是唯一的。
  • 编译进行中:当后台线程正在编译合约时,对该合约的调用使用解释器。
  • 编译错误:如果编译失败(例如,字节码超过代码大小限制或使用了编译器无法处理的模式),合约将永久回退到解释器。

正确性

编译器保留了精确的 EVM 语义。Gas 计费完全相同;无论解释还是编译,每个操作码收取的 Gas 是一致的。栈行为完全相同:相同的值以相同的顺序入栈和出栈。状态效应完全相同:发生相同的存储读取和写入。

Category Labs 团队运行一个模糊测试器 (fuzzer),生成随机 EVM 字节码序列,并验证解释器和 JIT 编译器是否产生相同的结果。

唯一的区别是速度。编译后的合约执行速度更快,因为消除了逐条指令的开销,预计算了常量,并且值存储在寄存器中而不是软件栈中。这种优化对于智能合约开发者、用户以及任何与 EVM 交互的工具都是不可见的。

想了解更多?请查看 代码

  • 原文链接: x.com/keoneHD/status/204...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
keonehd
keonehd
江湖只有他的大名,没有他的介绍。