https://blog.matter-labs.io/zksync-2-0-updates-1ae2b9bb9ff0
In this post, we will be sharing the R&D process of attempting not one, but two, things that have never been done before, how the redesign of zkSync 2.0 is more efficient and compatible, and the challenges of using LLVM with an architecture as unique as zk EVM.
The design of zkSync 2.0 involves two things that have never been done before:
Stack manipulation opcodes (such as popN, dup) prepare data and do not perform meaningful computation, yet they often make up a considerable cost of the transaction. Such semantics is inefficient for zkSNARKs, so our zkEVM uses a different operand addressing approach, but transformation from one to another required extra work on the compiler side.
By compiling to a SNARK-friendly zkEVM, we can have more versatile operand addressing for opcodes, bring down the costs of transactions, reduce the latency of proofs, and provide extra benefits such as amortized public data usage by many transactions in a block.
Designing for both zkRollup and zkPorter accounts in the same address space with interaction can be likened to ETH 2.0 sharding but with synchronous execution. Additionally, interactions between the two must provably fail to interact if one of the “shards” is not available.
Going beyond efficient execution of Solidity smart contracts to make the protocol feel like Ethereum was also a very complex task.
We previously discussed that our new design is more efficient and compatible with Ethereum. Here are some of the changes we made:
Updated the memory implementation to allow us more memory accessed per cycle of the zkEVM. In addition, all accesses to the same contract’s code per block are now “amortized.”
Added a dynamic pricing model: gas in zkSync is largely fixed by the price to prove it (eg 1 cycle = 1 unit), but if the user uses public data then it is dynamically priced in units to reflect both Ethereum gas price and ETH price.
To support Solidity smart contracts, we translate Yul IR into LLVM IR. We built the Yul frontend by making all Yul instructions compatible with the LLVM framework and the target virtual machine: zk EVM. The primary challenge is that we must translate Yul as is (and optimize the result later!); we cannot alter it in any way.
While the Yul syntax is minimal and easy to parse, some instructions and patterns need workarounds to be compatible with our zkEVM. Let’s dive into a few fun examples:
Unaligned Memory Access
In the EVM, memory is addressed with byte offsets:
On the other hand, in the zkEVM, memory is addressed with cell offsets, where each cell still consists of 32 bytes, but each cell is atomic and can only be read or written as a whole:
In order to translate an instruction like mload(4), we must load 28 bytes from cell 1, then 4 bytes from cell 2, and merge them into a single 32-byte value using bitwise operations.
Luckily, Yul rarely uses offsets that are not multiples of 32. Usually, the cases are:
All the cases above require cell splitting and merging, increasing each memory access instruction complexity by several times for non-constant addresses. There are some proposals for optimizing the Yul generator behavior for our zk EVM needs, for example, storing the external contract entry signature hash in its own 32-byte chunk, as well as merging error message chunks in Yul before writing them to memory.
Special Case A: Handling Calldata
In the Solidity contract ABI, the first 4 bytes of the calldata are the entry signature hash. Then, each argument is stored at the offset 4+32n.
In the zkEVM contract ABI, we add a 252-byte header to the calldata with some additional information:
Then, we put the entry signature hash and arguments starting at the area 252.., and load each argument with aligned memory access at offsets 256, 288, 320, etc.
It is achieved by checking the address in the calldataload(A) instructions. If the address is zero, we load the entry signature hash from the area 224..256, otherwise, we load an argument from the area A+252..A+252+32.
Special Case B: Adapting the Constructor
In Solidity, constructor arguments are deployed together with the contract bytecode at the end. In zk EVM, the constructor is an ordinary contract entry which can only be called once; a guard flag is set in storage at the end of its first execution.
Preserving Execution Flow
A lot of Yul instructions have been stubbed to preserve the expected execution flow. For example, Yul checks the external contract size using the extcodesize
instruction before making an external call to it. We do not make such a check before calls, so we always return MAX_VALUE to pass the code size check. Among other stubbed instructions are pc, callvalue, msize, balance, etc.
Our architecture had a few peculiarities that we had to spend some time integrating into the LLVM framework:
One of the main advantages of using LLVM is we benefit from their massive test suite repository:
We hope you enjoyed this window into the challenges we faced with the compiler and the R&D process of zkSync 2.0!
If this was interesting to you, we are hiring! If you have any questions, ask in our Discord!
在这篇文章中,我们将分享尝试的研发过程不是一个,而是两个,以前从未做过的事情,zkSync 2.0 的重新设计如何更高效和兼容,以及使用具有独特架构的 LLVM 的挑战作为 zk EVM。
zkSync 2.0的设计涉及到两件以前从未做过的事情:
堆栈操作操作码(例如 popN、dup)准备数据并且不执行有意义的计算,但它们通常构成交易的相当大的成本。这种语义对于 zkSNARKs 来说是低效的,所以我们的 zkEVM 使用了不同的操作数寻址方法,但是从一个到另一个的转换需要在编译器端进行额外的工作。
通过编译成对 SNARK 友好的 zkEVM,我们可以为操作码提供更通用的操作数寻址,降低交易成本,减少证明的延迟,并提供额外的好处,例如一个块中许多交易的摊销公共数据使用。
在同一个地址空间中为 zkRollup 和 zkPorter 账户进行交互设计可以比作 ETH 2.0 分片但具有同步执行。此外,如果其中一个“碎片”不可用,则两者之间的交互必须可证明无法交互。
除了高效执行 Solidity 智能合约之外,让协议看起来像以太坊也是一项非常复杂的任务。
我们之前讨论过我们的新设计更高效并且与以太坊兼容。以下是我们所做的一些更改:
对于读取
请求:任何语言的任何兼容 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 兼容。让我们深入研究几个有趣的例子:
未对齐的内存访问
在 EVM 中,内存使用字节偏移量进行寻址:
另一方面,在 zkEVM 中,内存是通过单元偏移来寻址的,其中每个单元仍然由 32 个字节组成,但每个单元是原子的,只能作为一个整体进行读取或写入:
为了翻译像 mload(4) 这样的指令,我们必须从单元 1 加载 28 个字节,然后从单元 2 加载 4 个字节,并使用按位运算将它们合并成一个 32 字节的值。
幸运的是,Yul 很少使用不是 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 框架中:
使用 LLVM 的主要优势之一是我们受益于他们庞大的测试套件存储库:
我们希望您喜欢这个窗口,了解我们在编译器和 zkSync 2.0 的研发过程中所面临的挑战!