1. 引言
RISC Zero zkVM 设计并实现为 与物理CPU功能类似。从而对于RISC Zero zkVM guest程序:
- 可使用通用编程语言(如Rust)和通用工具(如Cargo,LLVM)。
- 通常,也可使用通用优化技术来优化。
在RISC Zero zkVM的某应用中,guest程序 为zkVM待执行和证明的代码。
guest应具有reading、writting 和 committing的基本功能,具体为:
- 1)读取输入。相关方法有:
env::read
、env::read_slice
、env::stdin
。 - 2)将private outputs 写入到host。所谓host,是指运行zkVM的系统。相关方法有:
env::write
、env::write_slice
、env::stdout
、env::stderr
。 - 3)将public outputs commit到journal。相关方法有:
env::commit
、env::commit_slice
。
所谓journal,为receipt的一部分,包含了某zkVM应用的public outputs。而receipt用于证明某guest程序的有效执行。验证receipt可提供密码学的保证——该journal确实构建自expected circuit和expected imageID。- receipt中包含:
- journal:用于证实该guest程序的public outputs。
- seal:为难懂的blob,来密码学证实该receipt的有效性。
- receipt中包含:
为证明guest程序的正确执行,其遵循如下流程:
- 1)将guest程序编译为ELF二进制。
- 2)Executor运行该ELF二进制,并记录其session。
- 3)Prover检查并证明该session的有效性,输出receipt。
- 4)任何具有receipt的人都可验证该guest程序的执行,并读取其public outputs。该验证算法会以ImageID为参数,其中ImageID用作特定ELF二进制的密码学标识。
为让zkVM应用性能尽可能好,需:
- 让guest程序尽可能轻量级,为此
#![no_std]
,guest程序不能使用std
。 - guest程序永远无法以单独Rust可执行程序启动,因此设置
#![no_main]
。 - 为让guest程序可供host启动,需指定host启动guest程序该调用哪个函数,因此使用
risc0_zkvm_guest::entry!
宏来指定所调用的guest初始函数。
为优化zkVM程序,当前提供了如下debug和性能分析工具:
- Count cycles:
env::get_cycle_count
- 打印debug消息:
env::log
本文将重点关注:
- guest程序优化技巧:有更快的证明,或(和),降低计算开销。
- RISC Zero zkVM与物理CPU的关键差异
为实现更快的证明,或(和),降低计算开销,除对guest程序优化之外,还可利用硬件加速。借助NVIDIA图形卡,可通过CUDA来实现证明加速。当运行某zkVM程序时,需安装兼容版本的CUDA runtime。当从源码构建zkVM时,需在build机器上安装兼容版本的CUDA tookit,并启用cuda
feature。
1.1 何为zkVM?
zkVM,本质为CPU。
RISC Zero zkVM为RISC-V架构的实现,更确切来说,是实现了riscv32im。其与笔记本上基于X86架构或ARM架构所实现的CPU类似。
zkVM与物理CPU之间的最大不同之处在于:
- zkVM中,以软件,实现了算术电路,而不是硅铜形式构建的电路。
1.2 何为“cycle”?
zkVM和物理CPU中,运算的开销都是以“clock cycles”来衡量。
直观来说,一个“clock cycle”为CPU运算中的最小时间衡量单位,表示CPU内部时钟的一个tick,且为执行某基础CPU运算(如2个整数求和)所需时间。后续将其简称为“cycle”。
zkVM的证明时长与execution中的cycle数,直接相关。
2. guest程序优化技巧及建议
可使用通用技术和最佳实践来优化guest程序。
通用优化技术:
- The Rust Performance Book 为非常赞的资源。其篇幅不长,覆盖了性能的各个重要议题,并提出了优化实用建议。若是优化新手,或Rust新手,推荐阅读该资源。
最佳实践优化技巧:
-
1)Don’t assume, measure:性能是复杂的,无论是zkVM中的性能,还是物理CPU中的性能。不要假设你知道瓶颈在哪,而是要测量并试验。
- 若对执行用时的1%优化,哪怕将其提升100倍,该改进对总体性能的影响仍少于1%。这通常称为Amdahl定律,实际这意味着不应浪费时间来优化一些不花费大量执行时间的东西。
- 通过控制台打印来测量:
- 在guest程序中添加
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数。 }
- 在guest程序中添加
-
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。
2.1 当读取数据作为raw bytes时,使用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();
2.2 当仅需部分输入数据时,尝试对其Merkle化
某些程序仅需可用的整个数据的一部分。如Where’s Waldo例子:
- 其输入为一整张图,但仅需要有Walo的部分。
- 加载和对整张图进行哈希将非常昂贵,因此,对guest的初始输入仅为Merkle root,每个chunk动态加载。
- guest会通过Merkle inclusion proof来验证该chunk确实是该image的一部分。
当所写guest具有large input,且需要一部分输入来计算时,可考虑将large input切分为chunks,然后构建一棵Merkle tree。具体可查看Where’s Waldo代码。
2.3 guest中的密码学操作可利用accelerator circuits
RISC Zero的riscv32im实现中包含了一些特殊用途的运算,包含2个对密码学函数的“accelerator”:
- SHA-256:一次SHA-256压缩运算,通常对每个64-byte block需要68个cycle且初始化需要6个cycle。【这包含了基础的内存操作cycle,而不包含所触发的page-in或page-out操作。】
- 256-bit modular multiplication:一次256-bit modular multiplication需要10个cycle。【这包含了基础的内存操作cycle,而不包含所触发的page-in或page-out操作。】
通过在zkVM中“hardware”实现这些运算,使用这些accelerator的程序可执行得更快,并以少得多的资源完成证明。
详情见:
使用accelerator,一次SHA-256压缩运算,通常对每个64-byte block需要68个cycle且初始化需要6个cycle。
2.4 其它优化尝试
-
尝试不同的编译配置:
- 设置
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
。
3. RISC Zero zkVM与物理CPU的关键差异
使用通用建议和工具来优化guest程序,80%能凑效。但,物理CPU与zkVM之间存在关键差异,理解这些差异,有助于获取尽可能最好的guest行囊够。
本节重点关注RISC Zero zkVM与物理CPU的关键差异,因其与guest性能相关:
- 1)大多数RISC-V运算仅需要1个cycle。zkVM中指令间的相对差异要小得多。
- 2)内存访问需要1个cycle,page-in和page-out情况除外。
- 3)zkVM没有原生浮点运算
- 4)zkVM未对齐数据访问要贵得多
- 5)zkVM内存访问是同步的
- 6)zkVM所有执行都是单线程的
- 7)zkVM无pipelining或其它指令级的并行化
3.1 大多数RISC-V运算仅需要1个cycle
大多数RISC-V运算仅需要1个cycle。并不是所有运算需要的cycle数都一样。对于物理CPU和zkVM来说,都有:add
指令需要的cycle数少于div
指令。
但是,zkVM中指令间的相对差异要小得多:
- 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中:
- Addition, comparison, jump, shift left, load 和 store 均需要1个cycle。
- Bitwise operations (AND, OR, XOR), division, remainder, 和 shift right 均需要2个cycle。
这即意味着,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 |
3.2 内存访问需要1个cycle,特例情况除外
RISC-V运算,需要先将数据从内存加载到寄存器,然后再做实际运算(如,用作add
运算的输入)。还必须将寄存器中的值写回到内存中,以存储结果。内存的加载和存储(即读写)通常需要1个cycle。
内存访问,即读和写需要1个cycle,例外情况为page-in和page-out运算。
注意,与物理CPU相比,zkVM中的page非常快(衡量单位为cycle数):
- 在物理CPU中,访问L1 cache中的某值,甚至需要3到4个cycle。访问L3 cache中的值需要30到70个cycle,访问主内存中的值需要100到150个cycle。应用程序中最常见的瓶颈之一是访问内存中的数据所需的往返时间。
3.2.1 paging
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操作,需要1094到5130个cycle,平均需要1130个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类似:
- 使用更少的page
- 重复使用相同的page,而不是随机访问模式
- 压缩所访问的地址
都有助于降低paging开销。最好做试验来验证相应的优化效果。
3.3 zkVM没有原生浮点运算
RISC Zero zkVM未实现RISC-V浮点指令。因此,所有的浮点运算都以软件模拟。相比于1到2个cycle的整数运算,对浮点的基础运算(如加减乘除)需要60到140个cycle。
如有可能,尽可能使用整数来代替浮点数。
3.4 未对齐数据访问要贵得多
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索引。
3.5 内存访问是同步的
在物理CPU中,内存访问与寄存器操作是异步的,即意味着寄存器上的算术或逻辑运算运行的同时,该CPU在等待源自内存的结果。因为内存fetch延迟很大(为add
两个寄存器时长的100到150倍),从而在处理器和应用层都有prefetching和speculative execution技术。
而在RISC Zero zkVM中,所有内存操作都是同步的,无论数据当前是否paged-in。内存prefetching对zkVM性能无益反而可能有害。
3.6 所有执行都是单线程的
zkVM执行有一个core和一个线程。因此,无需使用多线程。在guest程序中使用async
routines、locking、atomic操作,只会让guest程序变慢。
3.7 zkVM无pipelining或其它指令级的并行化
现代处理器有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
RISC Zero系列博客
- RISC0:Towards a Unified Compilation Framework for Zero Knowledge
- Risc Zero ZKVM:zk-STARKs + RISC-V
- 2023年 ZK Hack以及ZK Summit 亮点记
- RISC Zero zkVM 白皮书
- Risc0:使用Continunations来证明任意EVM交易
- Zeth:首个Type 0 zkEVM
- RISC Zero项目简介
- RISC Zero zkVM性能指标
- Continuations:扩展RISC Zero zkVM支持(无限)大计算
- A summary on the FRI low degree test前2页导读
- Reed-Solomon Codes及其与RISC Zero zkVM的关系
- RISC Zero zkVM架构
- RISC-V与RISC Zero zkVM的关系
- 有限域的Fast Multiplication和Modular Reduction算法实现
- RISC Zero的Bonsai证明服务
- RISC Zero ZKP协议中的商多项式
- FRI的Commit、Query以及FRI Batching内部机制
- RISC Zero的手撕STARK