这篇文章深入探讨了MTCannon的实现,MTCannon是Optimistic Rollup中使用的容错证明虚拟机(FPVM),它模拟了支持多线程的MIPS64架构。
简介: 乐观rollup 依赖于 故障证明(fault proofs) 来验证 状态转换(state transitions),从而在防御者和挑战者群体之间实现 争议游戏(dispute game) 以解决不一致问题。MTCannon 是一个 故障证明虚拟机 (FPVM),它模拟具有多线程的 MIPS64,从而提高执行效率并启用垃圾回收。MIPS64.sol 是一个 Solidity 智能合约(smart contract),用于验证 EVM 中的单个 MIPS 指令,从而确保链上确定性的争议解决。在本博客中,我们将深入研究 MIPS64.sol 智能合约(smart contract) 的实现细节。
乐观 rollup,例如 Optimism 和 Base,基于以下假设运行:每个 状态转换(state transition) 都是有效的,除非被证明无效。这些系统批量处理链下 交易(transaction),并将最少的数据发布到以太坊,以提高可扩展性并降低成本。为了确保 状态转换(state transition) 的完整性,Optimism 和 Base 依赖于 故障证明(fault proofs),允许任何人挑战已发布的状态。为每个提议的 状态根(state root) 创建一个 争议游戏(dispute game),利用 二分游戏(bisection game) 来解决争议。任何一方都可以挑战已发布的状态。如果有人挑战已发布的状态,则会在 防御者(defender)(断言新状态正确)和 挑战者(challenger)(对此提出异议)之间展开 争议游戏(dispute game)。此过程将分歧缩小到单个 状态转换(state transition) 步骤,然后使用 MIPS64.sol 智能合约(smart contract) 在链上执行。
多线程 Cannon (MTCannon) 是一个 FPVM,它模拟具有内置多线程支持的 64 位大端架构。MIPS64.sol 由 Optimism 架构和开发,是基于 Solidity 的 FPVM 实现,用于验证链上单个 MIPS 指令证明。与之前的单线程 32 位版本的 Cannon 不同,MTCannon 在模拟环境中引入了多线程逻辑、线程调度、同步和管理,以便启用一项关键功能:垃圾回收(garbage collection)。此外,MTCannon 将内存架构从 32 位扩展到 64 位,这显着增加了可寻址的内存空间,并提高了更复杂计算的性能。
尽管以太坊虚拟机 (EVM) 本身不支持多线程,但 MIPS64.sol 模拟 MTCannon 的多线程模型,以保持与其基于 Golang 的对应项的一致性,这对于确定性地处理 争议游戏(dispute game) 至关重要。虽然两者都复制相同的 状态转换(state transitions),但它们扮演着根本不同的角色。Go 实现充当链下执行器,处理多线程、调度和 垃圾回收(garbage collection) 等复杂职责。另一方面,MIPS64.sol 是链上验证器 - 它不执行完整的程序,而是通过比较前后状态来一次验证一个指令步骤。MIPS64.sol 确定性地模拟多线程效果,但不在链上执行实际的多线程或 垃圾回收(garbage collection)。
MTCannon 通过 step() 函数一次执行一个指令,该函数处理 无互锁流水线阶段微处理器 (MIPS) 指令或 系统调用 (syscall),并相应地更新 VM 状态。当 挑战者(challenger) 和 防御者(defender) 达到 二分树(bisection tree) 的最大深度时 - 其中每个叶节点代表一个单独的中间指令 - 活动的 FaultDisputeGame 合约(contract) 使用特定指令调用 step(),以在链上解决争议。step() 函数进一步调用 doStep() 函数,该函数处理执行单个 MIPS 指令的主要逻辑。
在深入研究 MIPS64 的工作原理之前,我们提供一些基准测试数据来展示 MTCannon 的有效性。在之前一篇关于 32 位版本 Cannon 的基准测试的 博客文章(blog post) 中,有两个主要的 预编译(precompiles) 呈现资源约束:p256Verify 是时间密集型的,而 ECAdd 是内存密集型的。我们现在提供 MTCannon 的这些 预编译(precompiles) 的基准测试:
标签(Label) | Gas 使用量(Gas Usage) | 指令(Instructions) | 内存使用量(Memory Usage) | 运行时间(Runtime) |
1MGas | 1,000,000 | 3,064,043,857 | 57.82 MB <br>(减少 56%) | 132308 ms <br>(减少 90%) |
2MGas | 2,000,000 | 6,201,994,165 | 65.42 MB <br>(减少 50%) | 270057 ms <br>(减少 86%) |
3MGas | 3,000,000 | 8,853,403,266 | 70.99 MB <br>(减少 47%) | 390384 ms <br>(减少 85%) |
20MGas | 20,000,000 | 27,255,575,058 | 72.31 MB <br>(减少 53%) | 1071496 ms <br>(减少 91%) |
标签(Label) | Gas 使用量(Gas Usage) | 指令(Instructions) | 内存使用量(Memory Usage) | 运行时间(Runtime) |
---|---|---|---|---|
1MGas | 1,000,000 | 1,782,332,570 | 58.95 MB <br>(减少 59%) | 91368 ms<br>(减少 83%) |
2MGas | 2,000,000 | 3,636,652,866 | 66.30 MB <br>(减少 55%) | 183194 ms<br>(减少 68%) |
3MGas | 3,000,000 | 5,901,580,072 | 76.88 MB <br>(减少 52%) | 305114 ms <br>(减少 52%) |
20MGas | 20,000,000 | 7,877,161,385 | 86.16 MB <br>(减少 65%) | 359238 ms<br>(减少 71%) |
我们看到运行时间和内存使用量都比以前低得多,从而提高了 Base 的可扩展性。
doStep() 函数首先分配两个内存 结构体(structs):State 和 ThreadState。这些 结构体(structs) 使用 _stateData 和 _proof calldata 填充,这些数据存储重要的执行细节,例如 VM 的当前状态、活动线程的执行上下文、指令集等。State 结构体(struct) 维护全局 VM 数据,例如内存根和执行步骤,而 ThreadState 跟踪每个线程的执行细节,包括寄存器和程序计数器。State 中的 exited 字段确定 VM 是否已达到终止状态。
在加载任何状态之前,合约(contract) 会执行一系列检查,以确保编译器实际上将 state
和 thread
结构体(structs) 放置在预期的内存地址,验证空闲内存指针,并检查 _stateData.offset
和 _proof.offset
以确保 calldata 布局实际上与函数期望的匹配。我们在下表中总结了检查及其背后的原因:
偏移描述(Offset Description) | 字节数(Number of bytes) | 备注(Notes) |
---|---|---|
State 结构体(struct) | 0x80 = 128 | State 结构体(struct) 应放置在内存偏移量 0x80 处,即函数开始处的空闲内存指针。 |
Thread 结构体(struct) | 0x260 = 608 | Thread 结构体(struct) 应放置在内存偏移量 0x260 处。这是通过以下方式计算的:<br>State 结构体(struct) 偏移量 (128) + state 结构体(struct) 的大小 (15 个字段 * 32 字节) = 608。<br>ThreadState 结构体(struct) 包括:7 个单独的 32 字节字段(例如,threadID、exitCode、pc 等)、32 个寄存器(每个寄存器 32 字节大小)存储为 数组(array),1 个额外的 槽(slot) 用于存储 数组(array) 的长度。 |
空闲内存指针 | 59 字节 | 必须更新空闲内存指针以反映预期的内存布局,其中包括:<br>- 4 个保留的内存 槽(slots)(每个 32 字节),<br><br>- 来自 State 结构体(struct) 的 15 个字段,<br><br>- 来自 ThreadState 结构体(struct) 的 40 个字段。<br><br>将这些加在一起:4 + 15 + 40 = 59 字节。 |
State 数据偏移量 | 132 字节 | 验证 _stateData 偏移量,确保它设置为 132 字节,计算方法如下:<br>- 函数签名的 4 字节<br><br>- 指向 _stateData 的指针的 32 字节(第一个参数)<br><br>- _localContext 的 32 字节(第三个参数,固定 bytes32)<br><br>- _stateData 长度的 32 字节,这是动态参数所必需的。<br><br>将这些加在一起:4 + (32 × 4) = 132 字节。 |
Proof 数据偏移量 | 356 字节 | 验证 _proof 偏移量,确保它设置为 356 字节,计算方法如下:<br>- _stateData 偏移量的 132 字节。<br><br>- _stateData 为 188 字节,四舍五入到最接近的 32 的倍数,即 192。<br><br>- 接下来 32 字节分配给 _proof 的长度,这是动态参数所必需的。<br><br>将这些加在一起:132 + 192 + 32 = 356 字节。 |
通过验证后,以下代码块将 VM 状态从 calldata 加载到内存中。putField 函数从 _stateData 读取状态字段并将其存储在内存中。
function putField(callOffset, memOffset, size) -> callOffsetOut, memOffsetOut {
...
}
// 将状态从 calldata 解包到内存中
.
c, m := putField(c, m, 1)
c, m := putField(c, m, 1)
exited := mload(sub(m, 32))
.
然后检查状态的有效性,即
退出代码有效,如果已退出,则停止执行。
当前线程堆栈非空。
就像从 calldata 加载 VM 状态一样,以下函数从 _proof (calldata) 中提取 ThreadState 结构体(struct)。此过程包括:
验证 calldata 大小以确保它包含足够的字节来容纳完整的 ThreadState。
依次提取每个字段(例如,threadID、exitCode、pc 等)。
加载所有 32 个 通用寄存器 (GPR),这对于 MIPS 执行至关重要。
注意:MIPS64 恰好有 32 个 GPR。
setThreadStateFromCalldata(thread);
合约(contract) 维护两个线程堆栈:leftThreadStack 和 rightThreadStack,以结构化的方式管理并发。traverseRight 标志确定哪个堆栈当前处于活动状态:
如果 traverseRight == true,则右侧堆栈 (rightThreadStack) 处于活动状态。
如果 traverseRight == false,则左侧堆栈 (leftThreadStack) 处于活动状态。
将线程加载到内存后,以下函数计算线程的 哈希(hash),并将其与活动堆栈的 哈希(hash)(leftThreadStack 或 rightThreadStack)进行比较。如果计算出的 哈希(hash) 与预期的 哈希(hash) 不匹配,则执行恢复以防止篡改。
validateCalldataThreadWitness(state, thread);
根据线程状态,如果线程已退出,或者线程已用的时间量超过其 量子(quantum),则程序可以结束执行。
if (thread.exited) {
popThread(state);
return outputState();
}
if (state.stepsSinceLastContextSwitch >= sys.SCHED_QUANTUM) {
preemptThread(state, thread);
return outputState();
}
加载和验证状态后,从内存中提取要执行的当前指令。
uint256 insnProofOffset = MIPS64Memory.memoryProofOffset(MEM_PROOF_OFFSET, 0);
(uint32 insn, uint32 opcode, uint32 fun) =
ins.getInstructionDetails(thread.pc, state.memRoot, insnProofOffset);
此指令有三种可能的情况:
它是 Linux 系统调用 (syscall)
它是 读取-修改-写入 (RMW) 操作,即 load-linked 或 store-conditional
所有其他指令
在 MIPS64 中,所有指令的长度均为 32 位。每种指令格式由指令的前 6 位决定,这 6 位称为 操作码(opcode)。指令有三种类型:R 型、I 型和 J 型。R 型指令依赖于 func 字段(指令的最后 6 位),该字段与 操作码(opcode) 一起确定 R 型指令中的特定操作。
在 MIPS64 中,系统调用(syscalls) 被编码为 R 型指令。对于 系统调用(syscalls),MIPS64 规范保留 操作码(opcode) = 0(这意味着它是 R 型指令)和函数代码 0xC,这唯一地标识了 系统调用(syscalls)。
if (opcode == 0 && fun == 0xC) { return handleSyscall(_localContext); }
系统调用(syscalls) 的参数首先从 GPR 中检索。
// 从寄存器加载 syscall 编号和参数
(uint64 syscall_no, uint64 a0, uint64 a1, uint64 a2) =
sys.getSyscallArgs(thread.registers);
虽然有很多 系统调用(syscalls),但很少有影响 VM 状态的。因此,只有以下 系统调用(syscalls) 是有意义的:
忽略了几个 系统调用(syscalls),而未按其编号指定的那些系统调用将导致调用恢复。执行 系统调用(syscall) 后,将更新并返回状态。
Load-Linked (LL) 和 Store-Conditional (SC) 指令用于在 MIPS64 中执行原子操作。LL 从内存地址读取并设置“预留”,目的是对其进行更改。SC 尝试将新值存储到同一内存位置,前提是自 LL 指令发出后,没有其他线程修改过该内存(预留)。原子操作对于防止多线程运行时中的 竞争条件 (race conditions) 至关重要。
// 处理 RMW (read-modify-write) 操作
if (opcode == ins.OP_LOAD_LINKED || opcode == ins.OP_STORE_CONDITIONAL) {
return handleRMWOps(state, thread, insn, opcode);
}
if (opcode == ins.OP_LOAD_LINKED64 || opcode == ins.OP_STORE_CONDITIONAL64) {
return handleRMWOps(state, thread, insn, opcode);
}
每个线程都维护自己的执行上下文,包括 CPU 标量(scalar) 值,例如寄存器、程序计数器 (PC) 和其他架构状态变量。在执行指令之前,代码首先使用 getCpuScalars 函数检索当前线程的 CPU 状态,并构造一个 coreStepArgs 结构体(struct),将当前执行环境上下文传递给 MIPS 执行引擎。
st.CpuScalars memory cpu = getCpuScalars(thread);
ins.CoreStepLogicParams memory coreStepArgs = ins.CoreStepLogicParams({
.
memProofOffset: MIPS64Memory.memoryProofOffset(MEM_PROOF_OFFSET, 1),
.
});
MIPS 指令的实际执行发生在 execMipsCoreStepLogic 函数中:
(state.memRoot, memUpdated, effMemAddr) = ins.execMipsCoreStepLogic(coreStepArgs);
从高层次上讲,execMipsCoreStepLogic
函数执行以下步骤:
1. 指令解码和分类
根据 操作码(opcode) 确定指令类型(R 型、I 型或 J 型)。
如果指令是跳转(J 型),它会通过计算新的程序计数器 (PC) 来处理跳转,并提前返回而无需进一步执行。
2. 操作数检索
根据需要从寄存器中提取源操作数(rs、rt)。
R 型指令检索两个源操作数(rs 和 rt),而 I 型指令使用一个寄存器操作数(rs)和一个立即数。
处理特殊的 64 位加载指令。
3. 执行算术和逻辑运算 (ALU)
使用 ALU 执行核心算术/逻辑运算。
如果指令是 R 型,则函数代码 (funct) 确定确切的操作。
如果指令是 I 型,则某些指令会映射到等效的 R 型函数。
如果涉及内存操作(加载/存储),则会获取内存内容或准备存储一个值。
4. 状态更新 – 将计算结果存储在适当的寄存器或内存位置。
执行指令后,内存状态可能会根据执行的操作而更改。execMipsCoreStepLogic() 的返回值提供有关这些更新的信息:
内存是否被修改 (`memUpdated`)。
新的内存状态根 (`state.memRoot`)。
受影响的内存地址 (`effMemAddr`)。
此外,CPU 标量(scalar) 值会更新以反映执行状态,并且会重新计算线程根以保持多线程环境中的一致性。
(state.memRoot, memUpdated, effMemAddr) = ins.execMipsCoreStepLogic(coreStepArgs);
setStateCpuScalars(thread, cpu);
updateCurrentThreadRoot();
if (memUpdated) {
handleMemoryUpdate(state, effMemAddr);
}
最后,该函数通过返回 MIPS 状态的 哈希(hash) 来结束,该 哈希(hash) 封装了关键信息,例如内存承诺、堆分配、多线程堆栈状态和执行元数据。
随着乐观 rollup 向完全去中心化成熟,像 MTCannon 这样的 故障证明(fault proof) 系统在确保无信任和可验证的 状态转换(state transition) 方面发挥着至关重要的作用。通过扩展到 64 位架构并引入多线程,MTCannon 能够高效地执行复杂的工作负载,同时支持明确的争议解决。
感谢 Optimism 团队的努力,MIPS64 架构突破了 Layer 2 可扩展性、安全性和去中心化的可能性界限。如果你有兴趣从事保护 L2 系统的工作,请在此处浏览我们的空缺职位。你还可以关注我们的 X(Base 团队 和 Base 社区领导者)、Farcaster 和 Discord 以了解有关 Base 的最新信息。
- 原文链接: blog.base.dev/demystifyi...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!