译自 halo2 book:user/wasm-port.md
在 WASM 中使用 halo2
由于 halo2 是用 Rust 编写的,你可以把它编译成 WebAssembly (wasm),这样你就能在浏览器应用中为你的电路(circuit)使用证明器(prover)和验证器(verifier)了。本教程会带你了解将电路编译成 wasm 所需的全部知识。
在整个教程中,我们会参考 Zordle 的代码仓库作为示例,它是已知最早一批基于 Halo 2 电路的 webapp 之一。Zordle 是 ZK 版的 Wordle,其电路把玩家输入的单词以及玩家的分享格子(灰色、黄色和绿色方块)作为 advice 值,并验证它们是否正确匹配。因此,该证明(proof)验证了玩家知道输出分享表的一个"原像(preimage)",之后仅凭这份 ZK 证明就可以完成验证。
电路代码设置
第一步是在 Rust 中创建与浏览器应用对接的函数。对于证明器,这通常会输入某种形式的 advice 和 instance 数据,用它生成完整的见证(witness),然后输出一份证明。对于验证器,这通常会输入一份证明和某种形式的 instance,然后输出一个布尔值,表示该证明是否验证通过。
在 Zordle 的例子里,这部分代码位于 wasm.rs,由两个主要函数组成:
证明器(Prover)
#[wasm_bindgen]
pub async fn prove_play(final_word: String, words_js: JsValue, params_ser: JsValue) -> JsValue {
// Steps:
// - Deserialise function parameters
// - Generate the instance and advice columns using the words
// - Instantiate the circuit and generate the witness
// - Generate the proving key from the params
// - Create a proof
}
虽然具体的输入及其序列化方式取决于你的电路和 webapp 设置,但了解 Zordle 这个特定场景下的格式还是有用的,因为你的使用场景很可能与之类似:
这个函数把用户想要凑出的 final_word,以及他们尝试使用的单词(以 words_js 的形式)作为输入。它还把电路的参数作为输入,这些参数序列化在 params_ser 中。我们将在下面的 Params 小节中展开说明。
注意,函数参数是以与 wasm_bindgen 兼容的格式传入的:String 和 JsValue。JsValue 类型来自 Serde 库。你可以在这里的文档中找到关于这个类型及其用法的更多细节。
输出是一个用 Serde 转换成 JSValue 的 Vec<u8>。之后它会作为输入传给验证器函数。
验证器(Verifier)
#[wasm_bindgen]
pub fn verify_play(final_word: String, proof_js: JsValue, diffs_u64_js: JsValue, params_ser: JsValue) -> bool {
// Steps:
// - Deserialise function parameters
// - Generate the instance columns using the diffs representation of the columns
// - Generate the verifying key using the params
// - Verify the proof
}
与证明器类似,我们接收输入并输出一个布尔值 true/false,表示证明的正确性。diffs_u64_js 对象是一个二维 JS 数组,由每个单元格(cell)的值组成,这些值指示颜色:灰色、黄色或绿色。它们被用来组装电路的 instance 列。
参数(Params)
此外,证明器和验证器函数都会输入 params_ser,这是多项式承诺方案(polynomial commitment scheme)的公开参数的序列化形式。作为一种性能优化,这些参数是作为输入传入的(而不是在 prove/verify 函数中重新生成),因为它们仅根据电路的 K 值确定,是常量。我们可以把它们单独存放在一个静态 web 服务器上,然后作为输入传给 WASM。要生成这些参数的二进制序列化形式(在 WASM 函数之外单独生成),你可以运行类似下面的代码:
fn write_params(K: u32) {
let mut params_file = File::create("params.bin").unwrap();
let params: Params<EqAffine> = Params::new(K);
params.write(&mut params_file).unwrap();
}
之后,我们可以在 JavaScript 中从 web 服务器以 Uint8Array 这种字节序列化的格式读取 params.bin 文件,并把它作为 params_ser 传给 WASM,再在 Rust 中使用 js_sys 库进行反序列化。
理想情况下,将来我们应该能够不必序列化这些参数,而是直接序列化并使用电路的证明密钥(proving key)和验证密钥(verifying key),但目前该库尚不支持这一点,相关追踪见 issue #449 和 #443。
Rust 与 WASM 环境设置
通常,Rust 代码会用 wasm-pack 工具编译成 WASM,只需改改一些构建命令即可,非常简单。然而,对于 halo2 的证明器/验证器函数,我们需要对构建过程做一些额外的改动。具体来说,主要有两处改动:
- 并行(Parallelism):halo2 使用
rayon库来实现并行,而 WASM 并不直接支持它。不过,Chrome 团队提供了一个适配器,可以在浏览器中使用 Web Workers 实现类 rayon 的并行:wasm-bindgen-rayon。我们将用它在 WASM 证明器/验证器中启用并行。 - WASM 最大内存:
wasm-bindgen下 WASM 的默认内存上限被设为 2GB,这不足以为大型电路(K大于 10 左右)运行 halo2 证明器。我们需要把这个上限提高到 WASM 允许的最大值(4GB!),以支持更大的电路(直到K = 15左右)。
首先,把你的 WASM 对接函数所特有的所有依赖添加到 Cargo.toml 文件中。你可以通过 WASM 目标特性标志(target feature flag)把这些依赖限定在 WASM 编译范围内。在 Zordle 的例子中,它看起来是这样的:
[target.'cfg(target_family = "wasm")'.dependencies]
getrandom = { version = "0.2", features = ["js"]}
wasm-bindgen = { version = "0.2.81", features = ["serde-serialize"]}
console_error_panic_hook = "0.1.7"
rayon = "1.5"
wasm-bindgen-rayon = { version = "1.0"}
web-sys = { version = "0.3", features = ["Request", "Window", "Response"] }
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
接下来,让我们把 wasm-bindgen-rayon 集成到代码中。该库的 README 很好地概述了如何做到这一点。特别要注意对 Rust 编译流程的改动。你需要切换到 nightly 版本的 Rust,并启用对 WASM atomics 的支持。此外,记得在 Rust 代码中导出 init_thread_pool。
接下来,我们要把 wasm-pack 默认的 2GB 最大内存上限提上去。为此,在 .cargo/config 文件中为 wasm 目标添加 Rust 标志 "-C", "link-arg=--max-memory=4294967296"。完成 wasm-bindgen-rayon 的设置并提高内存上限后,.cargo/config 文件现在应该是这样的:
[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+atomics,+bulk-memory,+mutable-globals", "-C", "link-arg=--max-memory=4294967296"]
...
特别感谢 @mattgibb,他在一个不起眼的 GitHub issue 里记录了这个晦涩的、用于提高最大内存的改动。1
现在 Rust 这边已经设置好了,你应该可以直接用 wasm-pack build --target web --out-dir pkg 构建出一个 WASM 包,并在你的 webapp 中使用输出的 WASM 包了。
Webapp 设置
Zordle 附带了一个极简的 React 测试客户端作为示例(它只是在默认的 create-react-app 模板上加入了 WASM 支持)。你可以在这里找到这个测试客户端的代码。我建议你为自己的应用 fork 这个测试客户端,并以此为起点开始开发。
该测试客户端包含一个干净的 WebWorker,用于与 Rust WASM 包对接。把对接逻辑放进 WebWorker 中可以避免阻塞浏览器主线程,并为 React/应用逻辑提供一个干净的接口。查看 halo-worker.ts 了解 WebWorker 代码,并在 App.tsx 中看看如何从 React 与该 web worker 对接。
如果到目前为止你都做对了,那么你现在应该可以在浏览器中生成并验证证明了!在 Zordle 的例子里,一个 K = 14 的电路在我的笔记本上生成证明大约需要一分钟左右。在生成证明期间,如果你打开 Chrome/Firefox 的任务管理器,应该还能看到类似下面的画面:
Zordle 及其 test-client 默认把并行度设置为机器上可用的核心数。如果你想减少它,可以通过修改 initThreadPool 的参数来实现。
如果你更愿意使用自己的 Worker/React 设置,那么获取并序列化参数、证明以及其他 instance 和 advice 值的代码可能仍然值得一看!
Safari
注意,wasm-bindgen-rayon 库不被 Safari 支持,因为它会从一个 Web Worker 内部再派生(spawn)出新的 Web Workers。根据相关的 Webkit issue,对该特性的支持已于 2022 年 11 月进入 Safari Technology Preview,而且 Safari Technology Preview Release 155 的发行说明确实声称已支持,所以如果这一点对你很重要,值得去确认它是否已经进入正式版 Safari。
调试
你常常会遇到 Rust 代码出问题,看到 WASM 执行时报错 Uncaught (in promise) RuntimeError: unreachable,这是一个对调试完全没有帮助的错误。这是因为代码是以 release 模式编译的,作为性能优化它去掉了错误消息。要调试,你可以用 wasm-pack build 加上 --dev 标志,以 debug 模式构建 WASM 包。这会以 debug 模式构建,会显著拖慢执行速度,但能让你在浏览器控制台中看到任何运行时错误消息。此外,你可以安装 console_error_panic_hook crate(Zordle 就是这么做的),以便在发生运行时 panic 时也能得到有用的调试消息。
致谢
本指南由 Nalin 编写。另外感谢 Uma 和 Blaine 在弄清这些步骤上所做的大量工作。如果你在任何步骤上遇到困难,欢迎随时联系我。
-
题外话,但让我相当惊讶的是,WASM 有一个 4GB 内存的硬性上限。这是因为 WASM 目前是 32 位架构,对于这样一门崭新的、面向未来的汇编语言来说,这一点让我颇为意外。不过,确实有一些公开的提案,主张把 WASM 迁移到更大的地址空间。 ↩