RISC Zero zkVM 设计并实现为 与物理CPU功能类似。从而对于RISC Zero zkVM guest程序:
在RISC Zero zkVM的某应用中,guest程序 为zkVM待执行和证明的代码。
guest应具有reading、writting 和 committing的基本功能,具体为:
env::read
、env::read_slice
、env::stdin
。env::write
、env::write_slice
、env::stdout
、env::stderr
。env::commit
、env::commit_slice
。为证明guest程序的正确执行,其遵循如下流程:
为让zkVM应用性能尽可能好,需:
#![no_std]
,guest程序不能使用std
。#![no_main]
。risc0_zkvm_guest::entry!
宏来指定所调用的guest初始函数。为优化zkVM程序,当前提供了如下debug和性能分析工具:
env::get_cycle_count
env::log
本文将重点关注:
为实现更快的证明,或(和),降低计算开销,除对guest程序优化之外,还可利用硬件加速。借助NVIDIA图形卡,可通过CUDA来实现证明加速。当运行某zkVM程序时,需安装兼容版本的CUDA runtime。当从源码构建zkVM时,需在build机器上安装兼容版本的CUDA tookit,并启用cuda
feature。
zkVM,本质为CPU。
RISC Zero zkVM为RISC-V架构的实现,更确切来说,是实现了riscv32im。其与笔记本上基于X86架构或ARM架构所实现的CPU类似。
zkVM与物理CPU之间的最大不同之处在于:
zkVM和物理CPU中,运算的开销都是以“clock cycles”来衡量。
直观来说,一个“clock cycle”为CPU运算中的最小时间衡量单位,表示CPU内部时钟的一个tick,且为执行某基础CPU运算(如2个整数求和)所需时间。后续将其简称为“cycle”。
zkVM的证明时长与execution中的cycle数,直接相关。
可使用通用技术和最佳实践来优化guest程序。
通用优化技术:
最佳实践优化技巧:
1)Don’t assume, measure:性能是复杂的,无论是zkVM中的性能,还是物理CPU中的性能。不要假设你知道瓶颈在哪,而是要测量并试验。
eprintln!
来打印衡量某运算用时,以及调用次数等。env::get_cycle_count()
来获取程序当前位置的执行cycle数。【也可使用counts工具。】fn my_operation_to_measure() {
let start = env::get_cycle_count();
// potentially expensive or frequently called code
// ...
let end = env::get_cycle_count();
eprintln!("my_operation_to_measure: {}", end - start); //这样每次被调用时,会打印出相应的cycle数。
}
2)Profiling:为理解和优化代码的最重要的工具之一。Profiling工具有:【以找到cycles多的地方,作为优化点。】
来收集程序整个执行期间的性能信息,并创建可视化的性能图。RISC Zero已尝试支持为cycle counts生成pprof文件。pprof和perf所实现的 Sampling CPU profiles,提供了程序各处时间花费视图——通过记录sampling interval时的当前call stack。RISC Zero为guest执行提供了“sampling” CPU profiler。
一个很有用的数据化可视化为flamegraph火焰图。下图为ECDSA验证例子的火焰图:
需安装Go,并允许如下命令:
# In your clone of github.com/risc0/risc0
cd examples/ecdsa
RISC0_PPROF_OUT=ecdsa_verify.pb RISC0_DEV_MODE=true cargo run -F profiler
go tool pprof -http 127.0.0.1:8000 ecdsa_verify.pb
然后在浏览器中打开http://127.0.0.1:8000/ui/flamegraph,即可查看相应火焰图。尽管pprof
工具与Go关联,但其可用于profile非Go编写的程序。pprof
具有丰富的功能,详情可参看文档https://github.com/google/pprof/blob/main/doc/README.md。
更多zkVM guest程序profiling技巧,可参看Guest Profiling Guide。
env::read_slice
当向guest读取输入时,env::read
为主要api。其自动将输入bytes反序列化为structs,类似于password checker例子中的代码片段:
let request: PasswordRequest = env::read();
在host代码中,ExecutorEnvBuilder::write
用于序列化并写入到input struct,使得guest可读取:
let request = PasswordRequest { /* .. */ };
let env = ExecutorEnv::builder()
.write(&request).unwrap()
.build()
.unwrap();
大多数情况下,使用这些API来给guest发送数据。
但是,但需要读取并使用的数据为raw bytes(或words)时,使用env::read_slice
或env::stdin().read_to_end
会更高效。这2种方法都没有序列化和反序列化,因此无需复制或重解析输入数据。当按字节读取image数据,或按二进制编码读取数据时,这很有用。具体见CBOR。
Bonsai Governance例子代码片段中展示了如何读取字节:
let mut input_bytes = Vec::<u8>::new();
env::stdin().read_to_end(&mut input_bytes).unwrap();
对应host端,使用ExecutorEnvBuilder::write_slice
来按字节传输:
let input_bytes: Vec<u8> = b"INPUT DATA".to_vec();
let env = ExecutorEnv::builder()
.write_slice(&input_bytes)
.build()
.unwrap();
某些程序仅需可用的整个数据的一部分。如Where’s Waldo例子:
当所写guest具有large input,且需要一部分输入来计算时,可考虑将large input切分为chunks,然后构建一棵Merkle tree。具体可查看Where’s Waldo代码。
RISC Zero的riscv32im实现中包含了一些特殊用途的运算,包含2个对密码学函数的“accelerator”:
通过在zkVM中“hardware”实现这些运算,使用这些accelerator的程序可执行得更快,并以少得多的资源完成证明。
详情见:
使用accelerator,一次SHA-256压缩运算,通常对每个64-byte block需要68个cycle且初始化需要6个cycle。
尝试不同的编译配置:
lto="thin"
,有时比设置lto="fat"
或lto=true
要更快。opt-level=2
要比3
更快。也可以试试s
和z
。codegen-units=1
。当需要使用map时,用BTreeMap
而不是HashMap
。
当需要对数据哈希时,使用SHA-256 accelerator。
查找没必要复制 或 (反)序列化 的地方,改为使用使用env::read_slice
或env::stdin().read_to_end
。
使用通用建议和工具来优化guest程序,80%能凑效。但,物理CPU与zkVM之间存在关键差异,理解这些差异,有助于获取尽可能最好的guest行囊够。
本节重点关注RISC Zero zkVM与物理CPU的关键差异,因其与guest性能相关:
大多数RISC-V运算仅需要1个cycle。并不是所有运算需要的cycle数都一样。对于物理CPU和zkVM来说,都有:add
指令需要的cycle数少于div
指令。
但是,zkVM中指令间的相对差异要小得多:
div
指令,所需cycle数为add
指令的2倍。而物理CPU中,div
指令所需cycle数为add
指令的15到40倍。实际上,当实现某算法(The Most Efficient Known Addition Chains for Field Element & Scalar Inversion for the Most Popular & Most Unpopular Elliptic Curves),有10个add
运算,或,1个div
运算,这2个选项时。对于物理CPU,选择10个add
运算选项;而对于zkVM,则选择1个div
运算。zkVM中:
这即意味着,shift left 并不比 “乘以a power of two” 快,shift right 不比 division 快。开发者和编译器所做的类似这样的小优化,对zkVM来说并无效果。
物理CPU和RISC Zero zkVM中每种RV32IM运算所需的cycle数见下表:【见RISC-V Instruction Set Reference】
Assembly | Name | Pseudocode | RISC Zero Cycles |
---|---|---|---|
LUI rd,imm | Load Upper Immediate | rd ← imm | 1 |
AUIPC rd,offset | Add Upper Immediate to PC | rd ← pc + offset | 1 |
JAL rd,offset | Jump and Link | rd ← pc + length(inst)pc ← pc + offset | 1 |
JALR rd,rs1,offset | Jump and Link Register | rd ← pc + length(inst)pc ← (rs1 + offset) ∧ -2 | 1 |
BEQ rs1,rs2,offset | Branch Equal | if rs1 = rs2 then pc ← pc + offset | 1 |
BNE rs1,rs2,offset | Branch Not Equal | if rs1 ≠ rs2 then pc ← pc + offset | 1 |
BLT rs1,rs2,offset | Branch Less Than | if rs1 < rs2 then pc ← pc + offset | 1 |
BGE rs1,rs2,offset | Branch Greater than Equal | if rs1 ≥ rs2 then pc ← pc + offset | 1 |
BLTU rs1,rs2,offset | Branch Less Than Unsigned | if rs1 < rs2 then pc ← pc + offset | 1 |
BGEU rs1,rs2,offset | Branch Greater than Equal Unsigned | if rs1 ≥ rs2 then pc ← pc + offset | 1 |
LB rd,offset(rs1) | Load Byte | rd ← s8[rs1 + offset] | 1 if paged-in 1094 to 5130 otherwise |
LH rd,offset(rs1) | Load Half | rd ← s16[rs1 + offset] | 1 if paged-in 1094 to 5130 otherwise |
LW rd,offset(rs1) | Load Word | rd ← s32[rs1 + offset] | 1 if paged-in 1094 to 5130 otherwise |
LBU rd,offset(rs1) | Load Byte Unsigned | rd ← u8[rs1 + offset] | 1 if paged-in 1094 to 5130 otherwise |
LHU rd,offset(rs1) | Load Half Unsigned | rd ← u16[rs1 + offset] | 1 if paged-in 1094 to 5130 otherwise |
SB rs2,offset(rs1) | Store Byte | u8[rs1 + offset] ← rs2 | 1 if paged-in 1094 to 5130 otherwise |
SH rs2,offset(rs1) | Store Half | u16[rs1 + offset] ← rs2 | 1 if paged-in 1094 to 5130 otherwise |
SW rs2,offset(rs1) | Store Word | u32[rs1 + offset] ← rs2 | 1 if paged-in 1094 to 5130 otherwise |
ADDI rd,rs1,imm | Add Immediate | rd ← rs1 + sx(imm) | 1 |
SLTI rd,rs1,imm | Set Less Than Immediate | rd ← sx(rs1) < sx(imm) | 1 |
SLTIU rd,rs1,imm | Set Less Than Immediate Unsigned | rd ← ux(rs1) < ux(imm) | 1 |
XORI rd,rs1,imm | Xor Immediate | rd ← ux(rs1) ⊕ ux(imm) | 2 |
ORI rd,rs1,imm | Or Immediate | rd ← ux(rs1) ∨ ux(imm) | 2 |
ANDI rd,rs1,imm | And Immediate | rd ← ux(rs1) ∧ ux(imm) | 2 |
SLLI rd,rs1,imm | Shift Left Logical Immediate | rd ← ux(rs1) « ux(imm) | 1 |
SRLI rd,rs1,imm | Shift Right Logical Immediate | rd ← ux(rs1) » ux(imm) | 2 |
SRAI rd,rs1,imm | Shift Right Arithmetic Immediate | rd ← sx(rs1) » ux(imm) | 2 |
ADD rd,rs1,rs2 | Add | rd ← sx(rs1) + sx(rs2) | 1 |
SUB rd,rs1,rs2 | Subtract | rd ← sx(rs1) - sx(rs2) | 1 |
SLL rd,rs1,rs2 | Shift Left Logical | rd ← ux(rs1) « rs2 | 1 |
SLT rd,rs1,rs2 | Set Less Than | rd ← sx(rs1) < sx(rs2) | 1 |
SLTU rd,rs1,rs2 | Set Less Than Unsigned | rd ← ux(rs1) < ux(rs2) | 1 |
XOR rd,rs1,rs2 | Xor | rd ← ux(rs1) ⊕ ux(rs2) | 2 |
SRL rd,rs1,rs2 | Shift Right Logical | rd ← ux(rs1) » rs2 | 2 |
SRA rd,rs1,rs2 | Shift Right Arithmetic | rd ← sx(rs1) » rs2 | 2 |
OR rd,rs1,rs2 | Or | rd ← ux(rs1) ∨ ux(rs2) | 2 |
AND rd,rs1,rs2 | And | rd ← ux(rs1) ∧ ux(rs2) | 2 |
MUL rd,rs1,rs2 | Multiply | rd ← ux(rs1) × ux(rs2) | 1 |
MULH rd,rs1,rs2 | Multiply High Signed Signed | rd ← (sx(rs1) × sx(rs2)) » xlen | 1 |
MULHSU rd,rs1,rs2 | Multiply High Signed Unsigned | rd ← (sx(rs1) × ux(rs2)) » xlen | 1 |
MULHU rd,rs1,rs2 | Multiply High Unsigned Unsigned | rd ← (ux(rs1) × ux(rs2)) » xlen | 1 |
DIV rd,rs1,rs2 | Divide Signed | rd ← sx(rs1) ÷ sx(rs2) | 2 |
DIVU rd,rs1,rs2 | Divide Unsigned | rd ← ux(rs1) ÷ ux(rs2) | 2 |
REM rd,rs1,rs2 | Remainder Signed | rd ← sx(rs1) mod sx(rs2) | 2 |
REMU rd,rs1,rs2 | Remainder Unsigned | rd ← ux(rs1) mod ux(rs2) | 2 |
RISC-V运算,需要先将数据从内存加载到寄存器,然后再做实际运算(如,用作add
运算的输入)。还必须将寄存器中的值写回到内存中,以存储结果。内存的加载和存储(即读写)通常需要1个cycle。
内存访问,即读和写需要1个cycle,例外情况为page-in和page-out运算。
注意,与物理CPU相比,zkVM中的page非常快(衡量单位为cycle数):
RISC Zero zkVM的每次执行,都是基于初始内存状态开始的。该内存状态(又名image),由image ID索引,image ID中包含了对内存中所有数据所commit的Merkle root。基于效率考虑,内存中的数据切分为1kB pages。
RISC Zero zkVM中的page与操作系统中的page类似,选择该术语来特指memory paging,或,swapping。程序的执行会切分为continuation segments。segments之间,zkVM本质是休眠的,以节约所有working memory给host;因CPU将使用hard drive。
a segment中首次访问page,需要paged-in,加载自host。为确认该page的正确性,guest会验证该page相对其image ID的Merkle inclusion proof。这些哈希操作需要一定数量的cycle:
第一个page-in需要更多cycle——5130个cycle,因为其需要横穿该page table(即Merkle tree)直到root,对应的root等于image ID。一旦某path验证通过,就无需再次哈希,因此大多数page-in操作仅需要对leaf page(即data page)进行哈希。若某程序按顺序遍历内存,则其平均每个page需要1130个cycle,或每个字节对应1.35个cycle。
为在segment结束(即zkVM “休眠”)后支持continuation,需要page-out pages。page-out和page-in需要的操作数相同,因此,当首次将任意page写入到segment时,page-out开销为1094到5130个cycle。
若对应用profiling之后,了解page-in和page-out操作具有一定的开销,可优化应用以降低其内存使用和locality。这与优化data locality和L1/2 cache usage类似:
都有助于降低paging开销。最好做试验来验证相应的优化效果。
RISC Zero zkVM未实现RISC-V浮点指令。因此,所有的浮点运算都以软件模拟。相比于1到2个cycle的整数运算,对浮点的基础运算(如加减乘除)需要60到140个cycle。
如有可能,尽可能使用整数来代替浮点数。
CPU定义四了运算数据的标准size——word。在RISC-V 32-bit ISA中,一个word的size为32位(4个字节)。内存通常按words读写。
当读写某地址不是4字节的倍数时,该运算将更昂贵。在一个简单的benchm按人口中,读取未对齐的u32
值需要12个cycle,而读取对齐的u32
值仅需要1个cycle。
内存的所有分配默认都是对齐的,且编译器会帮助对齐,这通常不是问题。
若你定义的结构体中包含了多个小原语类型字段(如bool
、u8
、i16
等),且频繁访问该数据,则需要额外考虑对这些字段进行对齐。此外,若切成字节数组,注意做word-aligned索引。
在物理CPU中,内存访问与寄存器操作是异步的,即意味着寄存器上的算术或逻辑运算运行的同时,该CPU在等待源自内存的结果。因为内存fetch延迟很大(为add
两个寄存器时长的100到150倍),从而在处理器和应用层都有prefetching和speculative execution技术。
而在RISC Zero zkVM中,所有内存操作都是同步的,无论数据当前是否paged-in。内存prefetching对zkVM性能无益反而可能有害。
zkVM执行有一个core和一个线程。因此,无需使用多线程。在guest程序中使用async
routines、locking、atomic操作,只会让guest程序变慢。
现代处理器有execution pipelines,且Superscalar架构设计为并行执行指令。当pipeline保持为full,且使用独立执行单元时,指令吞吐量将更高。物理CPU实现无序和推测性执行,以及实现这一点的其他技术。
而RISC Zero的riscv32im实现非常简单。将从guest程序中读取指令,并按编译器选择的顺序执行。
开发者和编译器长使用如pre-fetching、avoiding branches或reordering指令等技术,来最大化指令级的并行化。但这些技术对zkVM根本无效。
[1] RISC Zero团队2023年11月视频 zkVM Guest Optimization Tips and Tricks (RISC Zero Study Club)
[2] Guest Optimization Guide
[3] Guest Code 101
[4] zkVM Overview
[5] Guest Profiling Guide