本文介绍了LambdaClass团队对ZKSync的EraVM(链下虚拟机)的重新实现工作,旨在提高其性能并探索通过BlockSTM添加并行执行的可能性。文章详细描述了开发过程,包括如何从检查字节码开始,逐步实现所有操作码,并使用Era Compiler Test Suite进行测试。此外,文章还深入分析了一个简单的ZKsync Era合约的汇编代码,解释了其执行流程和关键概念。
过去几周,我们一直在进行 ZKsync 的(链下)EraVM 的重新实现工作。目标是提高其目前的性能,并探索通过 BlockSTM 添加并行执行的可能性。为此,我们首先必须深入研究 EraVM 的工作原理以及它与 EVM 的区别。
我们要感谢 Anthony Rose 和 Matter Labs 团队在这个项目中的所有帮助,特别是他们的 new fastVM implementation,我们将其大量用作参考。
你可以在 our EraVM repository 上关注我们的进展。
重要的是说明我们的方法:即使这里的主要目标是提高性能,但我们的初始目标是不同的:我们想要一个简单的工作实现。
起初我们并不关心基准测试。我们知道我们的初始实现会很慢,但这并不是重点:重点是让一些简单的东西工作起来,以了解所有的活动部件。只有在这一切都到位后,我们才会将注意力转移到基准测试和性能上。
当我们开始时,我们对 EraVM 知之甚少。我们知道它与 EVM 不同,并且间接使用 zksolc 来编译合约并将其部署到网络,但没有过多地研究它的底层原理。
我们为了进入工作流程而做的第一件事是检查 VM 的字节码。我们将简单的合约编译成 EraVM 汇编代码,并开始熟悉它。当开始使用不熟悉的 VM 时,目标是设置一个简单的 fetch->decode->execute 循环,如下所示:
fn run(
vm: VM,
) {
loop {
let opcode = vm.get_opcode(&opcode_table)?;
match opcode {
Opcode::Add => todo!(),
Opcode::Sub => todo!(),
Opcode::Jump => todo!(),
Opcode::Mul => todo!(),
Opcode::Div => todo!(),
... => ...
}
vm.pc += 1;
}
...
}
然后逐步实现所有的操作码。当我们开始查看合约上生成的汇编时,我们意识到 EraVM 在操作码方面比 EVM 复杂得多;幸运的是,Matter Labs 有一份 very good primer on them 和一个 full formal specification。
在阅读了这些资料并阅读了他们自己的实现之后,我们偶然发现了他们自己的仓库 defining all the VM opcodes,从那里我们可以建立像上面那样的一个合适的循环。
有了它,我们开始编写我们自己的简单 EraVM 汇编程序,测试我们实现的所有不同的操作码。最终,在基本功能到位后,我们编写的这些简单的汇编程序开始不足以测试复杂的交互,比如合约调用其他合约、gas 管理等;我们需要一个合适的测试套件。
这个合适的测试套件是 era-compiler-tester,这是 Matter Labs 编写的 VM 的完整测试套件(从技术上讲,这也是 zksolc 编译器本身的测试套件,但我们关心的是这里的 VM 测试)。为了获得一个完整工作的 VM,我们意识到我们需要让这些测试通过。
在详细介绍它们之前,让我们快速概述一下我们设置要重新实现的 VM。
ZKsync 是一个旨在与 EVM 兼容的 zk-Rollup。在实践中,这可能意味着许多不同的事情。对于 ZKsync 来说,这意味着它在编程语言级别上是兼容的;这是通过 zksolc 完成的,这是一个由 Matter Labs 编写的基于 LLVM 的编译器,它接受任何 Solidity、Yul 或 Vyper 合约,并将其编译为 EraVM 字节码。
这可能看起来像是完全兼容,但事实并非如此。EraVM 具有与 EVM 完全不同的架构,并且有些差异无法完全抽象出来。
例如,以下 Solidity 合约:
contract Test {
function main(uint256 a, uint256 b) external pure returns(uint256 result) {
result = a + b;
}
}
编译成 EVM 汇编代码如下所示:
PUSH1 0x80
PUSH1 0x40
MSTORE
CALLVALUE
DUP1
ISZERO
PUSH1 0xE
JUMPI
...
而 EraVM 汇编代码如下所示:
add 128, r0, r3
st.1 64, r3
and! 1, r2, r0
jump.ne @.BB0_1
add r1, r0, r2
shr.s 96, r2, r2
and @CPI0_0[0], r2, r2
sub.s! 4, r2, r0
jump.lt @.BB0_2
ld r1, r3
...
显然,这些是非常不同的 VM。这需要在 ZKsync 的 VM 级别上工作时习惯这两种不同的架构。EVM 上的许多操作码在 EraVM 上都不是。
例如,EVM 有一个 returndatacopy 操作码,它将先前合约调用的输出数据复制到内存中。在 EraVM 上,没有这样的东西;对 Yul 合约中的 returndatacopy 的调用将编译成如下所示的代码块:
.BB0_19:
ld.inc r5, r7, r5
st.1.inc r6, r7, r6
sub! r6, r4, r0
jump.ne @.BB0_19
我们省略了一些上下文,但这本质上只是一个循环,它会不断地从被调用合约的内存中加载 (ld) 一个字,然后将其存储 (st) 到调用者合约的内存中,然后有条件地跳回 (jump.ne) 到循环,如果复制尚未完成(即,如果 sub! 指令没有产生零)。
这只是一个例子:大多数复杂的 EVM 操作码在 EraVM 上的工作方式类似。
在 era-compiler-tester 仓库中有数百万个测试,但它们都遵循相同的结构。每个测试都是一个 Solidity、Yul 或 Vyper 合约,该合约使用 zksolc 编译并使用某些输入运行,反过来又期望某些输出。例如,default.sol 测试如下所示:
//! { "cases": [ {\
//! "name": "first",\
//! "inputs": [\
//! {\
//! "method": "first",\
//! "calldata": [\
//! ]\
//! }\
//! ],\
//! "expected": [\
//! "42"\
//! ]\
//! }, {\
//! "name": "second",\
//! "inputs": [\
//! {\
//! "method": "second",\
//! "calldata": [\
//! ]\
//! }\
//! ],\
//! "expected": [\
//! "99"\
//! ]\
//! } ] }
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.16;
contract Test {
function first() public pure returns(uint64) {
uint64 result = 42;
return result;
}
function second() public pure returns(uint256) {
uint256 result = 99;
return result;
}
}
上面的注释指定了测试应该运行什么以及它期望什么。在这种情况下,有两个测试,它们应该运行方法 first 和 second,然后分别得到 42 和 99 作为结果。大多数测试都有很多注释,指定不同的运行,用不同的输入/输出测试不同的函数等等。
让我们编译上面的 default.sol 程序,看看它在底层做了什么。运行
zksolc default.sol --asm -o default --optimization 3 --overwrite
会将一个 default.zasm 文件放在 default 目录下。这是合约的 EraVM 汇编代码:
.text
.file "default.sol:Test"
.globl __entry
__entry:
.func_begin0:
add 128, r0, r3
st.1 64, r3
and! 1, r2, r0
jump.ne @.BB0_1
add r1, r0, r2
and! @CPI0_1[0], r2, r0
jump.eq @.BB0_2
ld r1, r1
shr.s 224, r1, r1
sub.s! @CPI0_2[0], r1, r0
jump.eq @.BB0_10
sub.s! @CPI0_3[0], r1, r0
jump.ne @.BB0_2
context.get_context_u128 r1
sub! r1, r0, r0
jump.ne @.BB0_2
add 42, r0, r1
st.1 128, r1
add @CPI0_4[0], r0, r1
ret.ok.to_label r1, @DEFAULT_FAR_RETURN
.BB0_1:
context.get_context_u128 r1
sub! r1, r0, r0
jump.ne @.BB0_2
add 32, r0, r1
st.2 256, r1
st.2 288, r0
add @CPI0_0[0], r0, r1
ret.ok.to_label r1, @DEFAULT_FAR_RETURN
.BB0_10:
context.get_context_u128 r1
sub! r1, r0, r0
jump.ne @.BB0_2
add 99, r0, r1
st.1 128, r1
add @CPI0_4[0], r0, r1
ret.ok.to_label r1, @DEFAULT_FAR_RETURN
.BB0_2:
add r0, r0, r1
ret.revert.to_label r1, @DEFAULT_FAR_REVERT
.func_end0:
.note.GNU-stack
.rodata
CPI0_0:
.cell 53919893334301279589334030174039261352344891250716429051063678533632
CPI0_1:
.cell 340282366604025813406317257057592410112
CPI0_2:
.cell 1519042605
CPI0_3:
.cell 1039457780
CPI0_4:
.cell 2535301202817642044428229017600
在深入研究之前,你需要了解一些关于 EraVM 的事情:
U256(256 位无符号整数)。r0 到 r15。
r0 是零寄存器:写入它不起作用,从它读取会产生零。r1 用作指向 calldata(即函数参数)的指针,以及从调用返回时指向 returndata 的指针。r2 通常存储有关当前调用是构造函数调用、常规函数调用还是系统调用(对具有特殊权限的系统合约的调用)的信息。让我们逐步概述这个汇编代码。
当有人调用这个合约时,执行总是从 __entry 符号开始。前两条指令正在做一些我们不太关心的设置,将值 128 存储到 r3 寄存器中:
add 128, r0, r3
st.1 64, r3
更详细地说,add 128, r0, r3 将 128 添加到 r0 中的值,并将其存储在 r3 中。因为 r0 是零寄存器,这本质上是将 128 存储在 r3 中(这是在 EraVM 中进行 mov s 到寄存器的方式)。
然后,st.1 将 r3 中的值存储到内存地址 64(如果你想知道 st.1 中的 1 是什么,它是要使用的堆的类型;EraVM 既有常规堆,也有特殊的辅助堆)。
然后,对 r2 寄存器进行检查并进行条件跳转:
and! 1, r2, r0
jump.ne @.BB0_1
and! 指令正在进行 1 和 r2 之间的按位 and 运算,将其存储到 r0,然后相应地设置零标志。这是存储到 r0,因为我们不关心结果。我们只是在检查 r2 寄存器是否为 1。如果是,那么这是一个构造函数调用,我们应该跳转到包含构造函数逻辑的块 @.BB0_1;如果不是,我们应该继续。
如果调用不是构造函数调用,那么代码将执行
add r1, r0, r2
and! @CPI0_1[0], r2, r0
jump.eq @.BB0_2
这会将 r1 中的 calldata 指针放入 r2,然后执行 and 指令和条件跳转,以确保它没有指向无效地址。如果是,则执行跳转到包含 revert 逻辑的块 @.BB0_2:
.BB0_2:
add r0, r0, r1
ret.revert.to_label r1, @DEFAULT_FAR_REVERT
如果地址有效,代码将如下执行:
ld r1, r1
shr.s 224, r1, r1
这是通过 ld 指令加载 calldata 指针指向的前 32 个字节,将其存储在 r1 中,然后将其向右移动 224 位,以仅保留其前 4 个字节(256 - 224 = 32 位 = 4 个字节)。
这 4 个字节是此合约调用的函数选择器。这个 default.sol 合约有两个函数
function first() public pure returns(uint64)
function second() public pure returns(uint256)
第一个函数的选择器是 0x3df4ddf4,而第二个函数的选择器是 0x5a8ac02d(你可以自己 here 检查它们)。如果将这些值转换为十进制,你将看到这些是汇编代码中标签 CPI0_3 和 CPI0_2 的值。这就是为什么代码执行 sub.s! 指令,比较 r1 中此选择器的结果与 CPIO_2
sub.s! @CPI0_2[0], r1, r0
jump.eq @.BB0_10
如果该值匹配,则执行跳转到块 .BB0_10,其中包含仅返回 99 的 second 函数的逻辑:
.BB0_10:
context.get_context_u128 r1
sub! r1, r0, r0
jump.ne @.BB0_2
add 99, r0, r1
st.1 128, r1
add @CPI0_4[0], r0, r1
ret.ok.to_label r1, @DEFAULT_FAR_RETURN
你可以看到 add 99, r0, r1 后面跟着 st.1 128, r1 将返回值存储到内存中。它之前的代码只是检查调用者是否传递了任何 wei,使用 context.get_context_u128 r1 指令,如果是,则 revert(此函数不可支付)。
如果选择器与 CPI0_2(second() 函数的选择器)不匹配,则代码将检查 first() 选择器(标签 CPIO_3):
sub.s! @CPI0_3[0], r1, r0
jump.ne @.BB0_2
在这种情况下,因为它是合约的最后一个有效函数选择器,如果该值不匹配,我们只需转到 revert 块 BB0_2。如果匹配,我们将继续执行 first() 函数的逻辑,进行相同的操作,但返回 42 而不是 99:
context.get_context_u128 r1
sub! r1, r0, r0
jump.ne @.BB0_2
add 42, r0, r1
st.1 128, r1
add @CPI0_4[0], r0, r1
ret.ok.to_label r1, @DEFAULT_FAR_RETURN
就是这样,这就是此合约的整个 EraVM 汇编代码。总而言之,代码的组织方式如下:
__entry 块是对此合约进行任何调用的入口点。BB0_1 包含合约的构造函数逻辑(在本例中为默认逻辑,因为我们自己没有编写一个)。BB0_10 包含 second() 函数的代码。BB0_2 仅具有 revert 逻辑。BB0_1。calldata 指针读取,如果它指向的地址无效,则通过跳转到 BB0_2 来 revert。CPI0_2 中的 second() 选择器检查提供的选择器。如果匹配,则跳转到块 BB0_10。first() 匹配。如果不匹配,则 revert,否则运行 first() 的代码。我们正在努力进行最后的修复,以使所有测试通过。一旦完成,我们的重点将完全转移到对 VM 进行基准测试并开始进行优化。为了预见这一点,我们已经开始与 ZKsync Era benchmarks 集成。这项工作需要 integrating the VM with the bootloader,这是 ZKsync 中执行区块的合约(本质上是网络的主要执行入口点)。
此引导加载程序集成还将允许我们让我们的 VM 插入到 ZKsync 运营商中,并开始尝试乐观的并行执行想法。实际上,获得并行执行可能需要修改引导加载程序或在运营商上执行时完全摆脱它,但这是另一篇文章的主题。
- 原文链接: blog.lambdaclass.com/how...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!