
在这篇文章中,我们将分享尝试的不是一个,而是两个以前从未做过的事情的研发过程,zkSync 2.0 的重新设计如何更高效和兼容,以及使用具有独特架构的 LLVM 的挑战作为 zk EVM。
zkSync 2.0的设计涉及到两件以前从未做过的事情:
zk EVM:对 SNARK 友好的 EVM 堆栈操作操作码(例如 popN、dup)准备数据并且不执行有意义的计算,但它们通常构成交易的相当大的成本。这种语义对于 zkSNARKs 来说是低效的,所以我们的 zkEVM 使用了不同的操作数寻址方法,但是从一个到另一个的转换需要在编译器端进行额外的工作。
通过编译成对 SNARK 友好的 zkEVM,我们可以为操作码提供更通用的操作数寻址,降低交易成本,减少证明的延迟,并提供额外的好处,例如一个块中许多交易的摊销公共数据使用。
不同的每账户数据可用性政策 在同一个地址空间中为 zkRollup 和 zkPorter 账户进行交互设计可以比作 ETH 2.0 分片但具有同步执行。此外,如果其中一个“碎片”不可用,则两者之间的交互必须可证明无法交互。
除了高效执行 Solidity 智能合约之外,让协议看起来像以太坊也是一项非常复杂的任务。
重新设计以提高效率和兼容性 我们之前讨论过我们的新设计更高效并且与以太坊兼容。以下是我们所做的一些更改:
现在可以仅使用我们的 Web3+ API 和 Ethers+ SDK 与我们的网络进行交互: 对于读取请求:
任何语言的任何兼容 web3 的框架都可以开箱即用,我们还将提供额外的 zkSync L2 特定功能 对于写请求:由于 L1 和 L2 之间的根本区别,您将不得不编写一些额外的代码(例如,zkSync 支持以任何代币支付费用,因此发送交易将涉及选择代币支付费用)
更新了内存实现,允许我们在 zkEVM 的每个周期访问更多内存。此外,每个区块对同一合约代码的所有访问现在都被“摊销”了。
增加动态定价模型:zkSync 中的 gas 很大程度上是由价格固定来证明的(例如 1 个周期 = 1 个单位),但如果用户使用公共数据,则它以单位为单位动态定价,以反映以太坊 gas 价格和以太币价格。
为了支持 Solidity 智能合约,我们将 Yul IR 转换为 LLVM IR。我们通过使所有 Yul 指令与 LLVM 框架和目标虚拟机兼容来构建 Yul 前端:zk EVM。主要挑战是我们必须按原样翻译 Yul(并在以后优化结果!);我们不能以任何方式改变它。
虽然 Yul 语法最小且易于解析,但一些指令和模式需要变通方法才能与我们的 zkEVM 兼容。让我们深入研究几个有趣的例子:
未对齐的内存访问
特例 A:处理呼叫数据
特例 B:适配构造函数
2. 保留执行流程
未对齐的内存访问
在 EVM 中,内存使用字节偏移量进行寻址:
mload(12):从区域 12..44 加载 32 个字节
mstore(16, X):将 X 存储在区域 16..48
calldataload(4):加载第一个 calldata 参数
另一方面,在 zkEVM 中,内存是通过单元偏移来寻址的,其中每个单元仍然由 32 个字节组成,但每个单元是原子的,只能作为一个整体进行读取或写入:
mov r1, 4:将寄存器r1的值存储到单元格4,即字节128..160
mov 4, r1:将单元格 4 中的值加载到寄存器 r1
为了翻译像 mload(4) 这样的指令,我们必须从单元 1 加载 28 个字节,然后从单元 2 加载 4 个字节,并使用按位运算将它们合并成一个 32 字节的值。
幸运的是,Yul 很少使用不是 32 倍数的偏移量。通常情况如下:
calldata 布局:每个参数使用签名哈希移动 4 个字节
错误消息编码:代码和描述以小于 32 字节的块形式出现
作为映射键的字符串文字通常不会占用整个 32 字节块
上述所有情况都需要单元拆分和合并,对于非常量地址,每条内存访问指令的复杂度都会增加数倍。有一些建议可以针对我们的 zk EVM 需求优化 Yul 生成器的行为,例如,将外部合约条目签名哈希存储在其自己的 32 字节块中,以及在将错误消息块写入内存之前在 Yul 中合并它们。
特例 A:处理呼叫数据
在 Solidity 合约 ABI 中,calldata 的前 4 个字节是入口签名哈希。然后,每个参数存储在偏移量 4+32n 处。
在 zkEVM 合约 ABI 中,我们向 calldata 添加了一个 252 字节的标头以及一些附加信息:
单元格中的调用数据大小
返回单元格中的数据大小
然后,我们将条目签名哈希和参数从区域 252.. 开始,并在偏移量 256、288、320 等处使用对齐的内存访问加载每个参数。
这是通过检查 calldataload(A) 指令中的地址来实现的。如果地址为零,我们从区域 224..256 加载条目签名哈希,否则,我们从区域 A+252..A+252+32 加载参数。
特例 B:适配构造函数
在 Solidity 中,构造函数参数与最后的合约字节码一起部署。在 zk EVM 中,构造函数是一个普通的合约入口,只能调用一次;在第一次执行结束时会在存储中设置一个保护标志。
保留执行流程
许多 Yul 指令已被存根以保留预期的执行流程。例如,Yul 在进行外部调用之前使用 `extcodesize` 指令检查外部合约大小。我们在调用之前不做这样的检查,所以我们总是返回 MAX_VALUE 来通过代码大小检查。其他存根指令包括 pc、callvalue、msize、balance 等。
我们的架构有一些特性,我们不得不花一些时间将其集成到 LLVM 框架中:
zkEVM 使用 256 位宽的寄存器,但 LLVM 在许多情况下本身并不支持 256 位整数
256 位是内存中的最小寻址区域,但 LLVM 硬编码假设内存寻址为 8 位字节
我们在堆栈中缺少绝对地址:所有地址都必须与堆栈指针相关,并且 LLVM 中没有这样的架构,因此我们不得不引入自定义通道来处理它(但是,我们计划在初始版本之后引入绝对地址)
VM 不提供显式读取或写入堆栈指针的能力,只能通过 push 和 pop 指令
出于安全原因,zkEVM 不支持跳转到任意目的地,仅支持代码中的静态已知地址
zkEVM 不支持有符号算术,我们为它实现了自定义降低
使用 LLVM 的主要优势之一是我们受益于他们庞大的测试套件存储库:
回归测试检查编译器生成合理的输出和优化是否按预期工作。核心 LLVM 存储库中有 36,000 个公共测试可用,我们从其中的 20,000 个中受益。(其他 16,000 个检查不同的 LLVM 后端)
可执行测试或集成测试运行编译器,但也运行编译器生成的二进制文件。在我们使用的 LLVM 测试套件存储库中,至少有 3,000 个这样的测试是公开可用的。
为了检查我们的整个编译器工作流程,我们实现了自己的可执行测试套件,目前有 1,000 个测试,使用所有支持的语言编写,包括 IR:Solidity、Zinc、Yul、LLVM IR 和 zkEVM 程序集。
我们希望您喜欢这个窗口,了解我们在编译器和 zkSync 2.0 的研发过程中所面临的挑战!
