RISC Zero zkVM guest程序优化技巧 及其 与物理CPU的关键差异

1. 引言

RISC Zero zkVM 设计并实现为 与物理CPU功能类似。从而对于RISC Zero zkVM guest程序:

  • 可使用通用编程语言(如Rust)和通用工具(如Cargo,LLVM)。
  • 通常,也可使用通用优化技术来优化。

在RISC Zero zkVM的某应用中,guest程序 为zkVM待执行和证明的代码。
在这里插入图片描述
guest应具有reading、writting 和 committing的基本功能,具体为:

  • 1)读取输入。相关方法有:env::readenv::read_sliceenv::stdin
  • 2)将private outputs 写入到host。所谓host,是指运行zkVM的系统。相关方法有:env::writeenv::write_sliceenv::stdoutenv::stderr
  • 3)将public outputs commit到journal。相关方法有:env::commitenv::commit_slice
    所谓journal,为receipt的一部分,包含了某zkVM应用的public outputs。而receipt用于证明某guest程序的有效执行。验证receipt可提供密码学的保证——该journal确实构建自expected circuit和expected imageID。
    • receipt中包含:
      • journal:用于证实该guest程序的public outputs。
      • seal:为难懂的blob,来密码学证实该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数。
      }
      
  • 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_sliceenv::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更快。也可以试试sz
    • 尝试设置codegen-units=1
  • 当需要使用map时,用BTreeMap而不是HashMap

  • 当需要对数据哈希时,使用SHA-256 accelerator

  • 查找没必要复制 或 (反)序列化 的地方,改为使用使用env::read_sliceenv::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中:

  • 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

AssemblyNamePseudocodeRISC Zero Cycles
LUI rd,immLoad Upper Immediaterd ← imm1
AUIPC rd,offsetAdd Upper Immediate to PCrd ← pc + offset1
JAL rd,offsetJump and Linkrd ← pc + length(inst)pc ← pc + offset1
JALR rd,rs1,offsetJump and Link Registerrd ← pc + length(inst)pc ← (rs1 + offset) ∧ -21
BEQ rs1,rs2,offsetBranch Equalif rs1 = rs2 then pc ← pc + offset1
BNE rs1,rs2,offsetBranch Not Equalif rs1 ≠ rs2 then pc ← pc + offset1
BLT rs1,rs2,offsetBranch Less Thanif rs1 < rs2 then pc ← pc + offset1
BGE rs1,rs2,offsetBranch Greater than Equalif rs1 ≥ rs2 then pc ← pc + offset1
BLTU rs1,rs2,offsetBranch Less Than Unsignedif rs1 < rs2 then pc ← pc + offset1
BGEU rs1,rs2,offsetBranch Greater than Equal Unsignedif rs1 ≥ rs2 then pc ← pc + offset1
LB rd,offset(rs1)Load Byterd ← s8[rs1 + offset]1 if paged-in 1094 to 5130 otherwise
LH rd,offset(rs1)Load Halfrd ← s16[rs1 + offset]1 if paged-in 1094 to 5130 otherwise
LW rd,offset(rs1)Load Wordrd ← s32[rs1 + offset]1 if paged-in 1094 to 5130 otherwise
LBU rd,offset(rs1)Load Byte Unsignedrd ← u8[rs1 + offset]1 if paged-in 1094 to 5130 otherwise
LHU rd,offset(rs1)Load Half Unsignedrd ← u16[rs1 + offset]1 if paged-in 1094 to 5130 otherwise
SB rs2,offset(rs1)Store Byteu8[rs1 + offset] ← rs21 if paged-in 1094 to 5130 otherwise
SH rs2,offset(rs1)Store Halfu16[rs1 + offset] ← rs21 if paged-in 1094 to 5130 otherwise
SW rs2,offset(rs1)Store Wordu32[rs1 + offset] ← rs21 if paged-in 1094 to 5130 otherwise
ADDI rd,rs1,immAdd Immediaterd ← rs1 + sx(imm)1
SLTI rd,rs1,immSet Less Than Immediaterd ← sx(rs1) < sx(imm)1
SLTIU rd,rs1,immSet Less Than Immediate Unsignedrd ← ux(rs1) < ux(imm)1
XORI rd,rs1,immXor Immediaterd ← ux(rs1) ⊕ ux(imm)2
ORI rd,rs1,immOr Immediaterd ← ux(rs1) ∨ ux(imm)2
ANDI rd,rs1,immAnd Immediaterd ← ux(rs1) ∧ ux(imm)2
SLLI rd,rs1,immShift Left Logical Immediaterd ← ux(rs1) « ux(imm)1
SRLI rd,rs1,immShift Right Logical Immediaterd ← ux(rs1) » ux(imm)2
SRAI rd,rs1,immShift Right Arithmetic Immediaterd ← sx(rs1) » ux(imm)2
ADD rd,rs1,rs2Addrd ← sx(rs1) + sx(rs2)1
SUB rd,rs1,rs2Subtractrd ← sx(rs1) - sx(rs2)1
SLL rd,rs1,rs2Shift Left Logicalrd ← ux(rs1) « rs21
SLT rd,rs1,rs2Set Less Thanrd ← sx(rs1) < sx(rs2)1
SLTU rd,rs1,rs2Set Less Than Unsignedrd ← ux(rs1) < ux(rs2)1
XOR rd,rs1,rs2Xorrd ← ux(rs1) ⊕ ux(rs2)2
SRL rd,rs1,rs2Shift Right Logicalrd ← ux(rs1) » rs22
SRA rd,rs1,rs2Shift Right Arithmeticrd ← sx(rs1) » rs22
OR rd,rs1,rs2Orrd ← ux(rs1) ∨ ux(rs2)2
AND rd,rs1,rs2Andrd ← ux(rs1) ∧ ux(rs2)2
MUL rd,rs1,rs2Multiplyrd ← ux(rs1) × ux(rs2)1
MULH rd,rs1,rs2Multiply High Signed Signedrd ← (sx(rs1) × sx(rs2)) » xlen1
MULHSU rd,rs1,rs2Multiply High Signed Unsignedrd ← (sx(rs1) × ux(rs2)) » xlen1
MULHU rd,rs1,rs2Multiply High Unsigned Unsignedrd ← (ux(rs1) × ux(rs2)) » xlen1
DIV rd,rs1,rs2Divide Signedrd ← sx(rs1) ÷ sx(rs2)2
DIVU rd,rs1,rs2Divide Unsignedrd ← ux(rs1) ÷ ux(rs2)2
REM rd,rs1,rs2Remainder Signedrd ← sx(rs1) mod sx(rs2)2
REMU rd,rs1,rs2Remainder Unsignedrd ← 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数):

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。

内存的所有分配默认都是对齐的,且编译器会帮助对齐,这通常不是问题。

若你定义的结构体中包含了多个小原语类型字段(如boolu8i16等),且频繁访问该数据,则需要额外考虑对这些字段进行对齐。此外,若切成字节数组,注意做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系列博客

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值