prover profile and backends — 2026-10-10
Owner directive (2026-10-10): proving runs on any device through joy —
Metal through honeycrisp on Apple Silicon, WebGPU through wgpu elsewhere,
CPU as the fallback — and takes seconds, never minutes. Goal row: soft3
proposals/proof-system-repair.md §0 and phase P (soft3#173). Contract of
the backends: lens specs/backend.md.
Stand: zheng feat/gpu-backend — first on feat/wrap-envelope (#57,
cede6bd: profiles 1 and 5 below, tables A–B), then rebased onto
feat/wrap-fastverify (#58, 379eda2: the streaming commitment and the
4i:16,8i,9f:24 chain; the wrap chain, table C); lens feat/gpu-backend
(lens#23, on feat/field-native-whir, #21); joy feat/gpu-backend (joy#40,
on feat/wrap, #39); honeycrisp main 9cbd480 (aruminium); strata main
494df7a. Baseline = the same fixtures built from the base commits (zheng
cede6bd, lens c91f55a, joy a6eedee) — or, for the wrap chain, #58's own
CPU path (ZHENG_BACKEND=cpu on this branch runs #58's code with lens's
CPU backend at the seams). Apple M4 Max,
16 cores (12P + 4E), 40-core GPU, 48 GB, macOS 26.4, rustc 1.95.0,
--release. The machine was shared with other agents through every run:
load average 20–78, other proofs resident (10–21 GB), and the GPU 62 %
busy with another client when idle-checked (ioreg Device Utilization).
Wall times are single runs under that load — upper bounds of a quiet
machine, noisy by ±50 %; CPU seconds (user) are the steadier figure.
Sizes and proof bytes are deterministic.
1. where prove time goes (before the backends)
Method: macOS sample (1 ms) over the whole prove, inclusive samples per
worker spawn site (thread samples, so CPU-milliseconds), plus
ZHENG_TIMING laps for wall time.
profile 1 (succinct), hash.tri — joy prove --succinct
17.7 s wall, 150 CPU-s, 38 MB. 100 % of the sampled prove is
lens::rspcs::pow::grind (WHIR grinding, 24 bits per round, ≈ 3.6 × 2²⁴
hemera hashes, one hemera::hash(seed ‖ nonce) call per nonce, no
batching). Relation, witness, commit, Spartan and the opening together take
≈ 45 ms (audit/succinct-profile-2026-10.md).
profile 5 (IVC), hash.tri — one 2¹⁵-row step, rate 1/16, pow 24
ivc_bench hash.tri: 32.7 s prove (load 17–20), 200 CPU-s, 6.2 GB.
phase (wall, ZHENG_TIMING) |
s |
|---|---|
| pre-commit (nox phase 1 word) | 1.1 |
| step: word b + word c commits | 2.2 |
| step: zerocheck | 3.6 |
| step: folds + shift | 0.2 |
| step: accumulation — sumcheck (serial eq tables, rounds, folds) | 4.4 |
| step: accumulation — commit, OOD, two grinds | 4.9 |
| decide (one batched WHIR opening) | 15.2 |
| operation class (CPU) | CPU-s | share |
|---|---|---|
hemera: grinding (sponge::grind) |
37.8 | 17 % |
| hemera: Merkle leaf sponges (base + ext words) | 55.5 | 25 % |
| hemera: Merkle nodes | 18.5 | 8 % |
zerocheck: relation evaluation over Fp3 (Relation::eval, CircuitAir) |
60.8 | 27 % |
| RS encoding (nebu NTT per coset) | 21.6 | 10 % |
| main thread, serial (sumcheck tables, folds, Horner, combinations) | 14 | 6 % |
Hemera — grinding and Merkle trees — is half the CPU; Fp3 constraint evaluation a quarter; the serial main thread decides the wall time of the accumulation sumcheck and the decider.
profile 6 (wrap chain)
Before #58 (audit/wrap-2026-10.md §2–3, CPU): add.tri IVC 26.8 s, wrap
levels 84.5 / 168.4 / 731.9 s, 32–38 GB peak; the final level's opening
dominated (grinding 30 bits per round, 2³⁰ permutations a round). #58
(audit/wrap-fastverify-2026-10.md) cut the chain to 83 s and 19.5 GB
(streamed codewords, key words committed once, chain 4i:16,8i,9f:24:
grinding 24 bits at most); its remaining time is ≈ 70 % commitment hashing
(coset NTT + leaf sponges of the streamed words) and ≈ 20 % grinding — the
two operations the backends take (§4 C). In the CPU run of table C (load
67–77) the batched permutation counter reads 1.15 × 10⁹ hemera
permutations for IVC + chain.
2. what was built
| piece | where |
|---|---|
Backend trait: permute (hemera, many states), grind (least nonce, two nonce injections), sponge (overwrite sponge per item: Merkle leaves), coset_ntt; Cpu; process-wide current/install |
lens rspcs/src/backend.rs, spec specs/backend.md |
| Metal backend: MSL kernels (native 64-bit, constants inlined), shared-storage (unified memory) buffers, honeycrisp aruminium | lens gpu/src/{metal,msl}.rs |
| WebGPU backend: WGSL kernels over 32-bit limbs, ≤ 2 storage buffers per kernel (PowerVR), wgpu 28; builds for wasm32 and Android | lens gpu/src/{webgpu,wgsl}.rs |
call sites: lens WHIR/TensorRs grinding, RS encoding; zheng permute_many (batches ≥ 4096), recursion grinding, leaf sponges of held words (one call per tree), streamed words (stream::digests_grouped: coset groups of ≥ 2¹⁶ leaves, CPU encoding pipelined with device hashing) |
lens pow.rs, rs.rs; zheng recursion/{perm,sponge,word,stream}.rs |
| CPU: grinding claims batches until the least nonce (was: 16 threads spawned per 64K nonces); threaded eq tables, scaled-eq sums, folds, ML and univariate evaluation; threaded WHIR word combinations (the accumulation sumcheck's threading of the first stack is #58's own now) | lens backend.rs, field.rs; zheng recursion/whir/prove.rs |
joy prove --backend auto|cpu|metal|webgpu (JOY_BACKEND), backend reported |
joy cli/prove.rs |
ZHENG_BACKEND in the benches |
zheng tests/common/mod.rs |
backend coverage
| operation | cpu | metal | webgpu |
|---|---|---|---|
| hemera permutation batch (Merkle nodes, sponge blocks) | ✓ | ✓ (≥ 4096 states) | ✓ (≥ 4096 states) |
| leaf sponges, whole batch in one call (held and streamed words) | ✓ | ✓ | ✓ |
| grinding, field-native lane (recursion) | ✓ | ✓ | ✓ |
grinding, hemera::hash(seed ‖ nonce) (lens WHIR) |
✓ | ✓ | ✓ |
| coset NTT (lens RS encoding) | ✓ | ✓ (≥ 2¹², parity with cpu) | — (cpu) |
coset NTT of streamed words (zheng stream.rs) |
✓ | — (cpu, pipelined with the device sponge) | — (cpu, pipelined) |
| lens byte-hash Merkle trees (profiles 1/4) | ✓ | — (cpu) | — (cpu) |
| sumcheck rounds, folds, eq tables, ML/univariate evaluation | ✓ threaded | — (cpu) | — (cpu) |
| zerocheck constraint evaluation (Fp3 relation) | ✓ threaded | — (cpu) | — (cpu) |
3. same bytes on every backend
gpu/tests/differential.rs(lens): permutations of random states with zero andp − 1lanes (4096, 5000, 70,001 states; all-zero states), grinding for 1–18 and 20 bits with both injections (nonces beyond the first dispatch), Metal with 2–4 states per thread sharing inversions, coset NTTs 2¹²–2¹⁶ — Metal and WebGPU equal to CPU; 6 tests,LENS_GPU_REQUIRE=metal,webgpugreen.- envelopes (
envelope_profiles,ZHENG_ENVELOPE_DIR): SHA-1 equal across baseline, cpu, metal, webgpu — hash.tri p131ebd6ff…, hash.tri p5049777ae…, merkle-32 p5416e4dc6…, tree-12 p579f89ba5…(baseline, metal, webgpu; the cpu tree-12 run was cut by a time limit). - joy artifacts:
joy prove --succinct hash.tribaseline / cpu / metal / webgpu alla3cb76d2…(16,308 B);joy verifyaccepts each. - wrap chain (#58 stack): IVC proof, wrap 0, wrap 1, wrap 2 and the final
proof stored by
wrap_bench— SHA-1 equal on cpu and metal in two independent pairs of runs (final67d5ab70…, 62,544 B). recursion::word::tests::device_paths_give_the_cpu_digests: the device paths (one sponge call per batch, coset groups) through a renamed CPU backend give the CPU digests, held and streamed, base and Fp3.- zheng
cargo test --release: 332 passed, 0 failed, 12 ignored on the pre-#58 stack (transcript_pin, recursion, recursive_envelope, wrap, wrap_review included); on the #58 stack (379eda2): 330 passed, 0 failed, 7 ignored; on 5d7c959: 331 passed, 0 failed, 7 ignored; round 2 on b7ad573: 333 passed, 0 failed, 7 ignored; joy on zheng#57 (e788c10) + lens round 2: 228 passed, 0 failed; lens rspcs 37 passed; joy 228 passed, 0 failed.
4. timings
All runs on the machine described above; load average at the start of each block in brackets. Proof bytes are identical in every column (§3).
A. profile 1 (succinct), hash.tri
joy prove --succinct hash.tri --input-values 7 (whole CLI, one run each,
load 30–49):
| backend | prove | peak RSS |
|---|---|---|
| baseline (before the backends) | 17.7 s | 38 MB |
| cpu | 5.5 s | 53 MB |
| metal | 1.36 s | 52 MB |
| webgpu (wgpu on Metal) | 2.8 s | 56 MB |
zheng library (envelope_profiles, prove only, two interleaved rounds,
load 57–93): baseline 16.5 / 7.7 s · cpu 1.9 / 2.4 s · metal 0.4 /
0.4 s · webgpu 0.6 / 0.7 s.
B. profile 5 (IVC), pre-#58 stack
envelope_profiles hash.tri tree-12 (ZHENG_PROFILES=1,5), two
interleaved rounds (load 57–93):
| fixture | baseline | cpu | metal | webgpu |
|---|---|---|---|---|
| hash.tri (1 step of 2¹⁵ rows) | 82.0 / 55.9 s | 36.2 / 42.2 s | 19.4 / 24.2 s | 27.9 / 29.4 s |
| tree-12 (2 steps) | 76.7 / 75.0 s | 63.6 / 70.7 s | 28.6 / 29.9 s | 40.6 / 28.9 s |
| process CPU time (p1 + p5 × 2 fixtures) | 584 / 577 s | 508 / 510 s | 110 / 112 s | 150 / 158 s |
Memory: 6.2–7.8 GB peak on every backend (the IVC's tables; the backends add the batch staging buffers only).
C. wrap chain (profile 6), #58 stack
wrap_bench add.tri (IVC proved, then the three levels; fresh
ZHENG_IVC_DIR per run), the two runs back to back (16:40–16:47, load
67–95):
| cpu (#58's path) | metal | |
|---|---|---|
| IVC prove | 28.7 s | 21.2 s |
| wrap 0 / 1 / 2 prove | 13.1 / 65.4 / 51.0 s | 17.3 / 55.6 / 39.7 s |
| wrap chain | 129.5 s | 112.5 s |
| key derivation (once per chain) | 2.0 + 6.7 + 46.7 + 1.9 s | 1.2 + 4.4 + 34.8 + 1.6 s |
| whole process | 215.9 s wall, 1,755 CPU-s | 176.2 s wall, 192 CPU-s |
| peak RSS | 9.4 GB | 10.8 GB |
| final proof | 62,544 B | 62,544 B, same bytes |
Inside the levels (metal vs cpu, same runs): grinding at 24 bits fell from 0.4–4.2 s to 0.1–1.2 s a round; the streamed commitments did not get faster (wrap 1 words 17.3 vs 18.4 s; round commits 10.4 / 6.4 / 12.5 vs 15.5 / 9.4 / 4.9 s) — the first device path built every coset group on one thread and then hashed it. A pipelined version (the CPU encodes the next coset group while the device hashes the current one; commit "device streaming pipelined") ran once at 16:50 under a heavier load (key derivation alone 2.9× slower than in the pair above): chain 29.7 / 54.8 / 31.2 s, process 332 CPU-s — not comparable to the pair; a quiet-machine pair is open.
Peers, for scale: #58's own run (audit/wrap-fastverify-2026-10.md, load
24–41): wrap 11.8 / 42.5 / 28.7 s = 83.0 s.
C′. wrap chain on #58 at 5d7c959 (lean streaming, IVC grinding 16 bits)
The same pair after rebasing onto #58's later head (2026-10-13 14:13–14:23, load 40 → 156: the CPU run's second half ran at a load of 110–156, so its level times are inflated; CPU seconds are the steadier comparison):
| cpu (#58's path) | metal | |
|---|---|---|
| IVC prove | 22.6 s | 9.6 s |
| wrap 0 / 1 / 2 prove | 26.6 / 126.3 / 86.2 s | 30.5 / 55.2 / 29.8 s |
| wrap chain | 239.1 s | 115.5 s |
| key derivation (once per chain) | 2.3 + 7.7 + 57.4 + 1.3 s | 2.1 + 12.7 + 109.8 + 2.6 s |
| whole process | 330.7 s wall, 1,771 CPU-s | 253.1 s wall, 330 CPU-s |
| peak RSS | 7.3 GB | 10.0 GB |
| proofs (IVC, wrap 0–2, final) | — | SHA-1 equal to cpu (final d9feb171…) |
The 1/256 level's key derivation (its key words always streamed now, Fp3 over 2²⁹ points) ran slower through the device path than on 16 cores in this pair (110 vs 57 s): the CPU still runs every coset NTT there and the lanes cross into the device buffer by a copy; open (§5, item 2).
D. kernels (cyber-lens-gpu gpu_bench)
| backend | hemera permutations | grind 24 bits (62.0 M nonces) | grind 30 bits (1.08 G nonces) | coset NTT 16 × 2²⁰ |
|---|---|---|---|---|
| cpu, 16 threads | 7–12 M/s | 5.1–11.6 s | 143.0 s | 83–173 ms |
| metal | 27–39 M/s | 1.4–1.9 s | 27.3 s | 63–107 ms |
| webgpu (on Metal) | 7–15 M/s | 3.1–3.6 s | 41.4 s | (cpu) |
The GPU was shared (62 % utilised by another client when sampled), so the
Metal figures are lower bounds of the device. Tried and kept off: 2–8
states per thread sharing the partial-round inversion (no gain: register
pressure), threadgroup sizes 32–1024, full loop unrolling, mulhi vs
explicit 32-bit limb products — all within noise of each other.
4b. round 2 (2026-10-13): zerocheck and streamed commitments on the device
On #58 at b7ad573 (4i:16,8i:24,9f:24):
- zerocheck on the device — an AIR records its combination
Σ_k μ^k C_kas anair::numgraph with the challenges andμfolded in as constants (Air::combine_graph, provided by the IVC step relation and the wrap circuit); compiled, depth-first scheduled and register-allocated into a lensprogram::Program(step relation: 12,311 operations, 67 registers — 891 values were live in recording order);Backend::line_sumsevaluates it along every pair's line at the round's points (Metal: an interpreter kernel, one thread a pair and point; WGSL: the same, three storage buffers). Tests: the program equals the graph; device rounds equal the AIR rounds (step relation, random rows, on and off the cube); Metal and WebGPU line sums equal the CPU interpreter. - streamed commitments on device and CPU together —
Backend::coset_leavesruns a group of cosets on the device (coefficient limbs in, coset NTTs and the leaf sponges reading the coset values in device memory, digests out — the codeword never leaves the device); zheng'sstream::digests_devicelets the device claim coset groups and 15 CPU workers claim single cosets from one counter, so the faster side takes more and a declining backend falls back to the CPU. This removed the key-derivation regression.
Same bytes: IVC proof, wrap 0/1/2 and the final proof SHA-1 equal on cpu
and metal (final d9feb171…); hash.tri IVC 5c08a7e6… on both.
| (add.tri, 15:03–15:09, load 50–150) | cpu | metal |
|---|---|---|
| IVC prove | 15.6 s | 9.6 s |
| key derivation (once per chain) | 4.0 + 7.4 + 66.1 + 2.6 s | 0.8 + 5.0 + 22.3 + 2.0 s |
| wrap 0 / 1 / 2 prove | 15.4 / 108.3 / 52.7 s | 10.4 / 36.2 / 18.4 s |
| wrap chain | 176.4 s | 65.0 s |
| whole process (keys + IVC + chain) | 272.4 s wall, 1,878 CPU-s | 105.5 s wall, 207 CPU-s |
| peak RSS | 12.0 GB | 13.2 GB |
hash.tri IVC step alone (ivc_bench, load 150): metal 14.1 s wall /
18 CPU-s, cpu 17.9 s / 121 CPU-s; the Metal run's own thread spent 4.9 s
of CPU (serial transcript, packing and WHIR work) and the machine gave it
a fraction of a core — the step is CPU-thread-bound on the device path,
not device-bound.
4c. round 3 (2026-10-13): the main thread, resident columns, device trees
On #58 at aff14b2.
- main thread — profiled with macOS
sampleon the Metal IVC step, attributing only the main thread's on-CPU samples (waits excluded,tools-side script): the serial work was the Reed–Solomon encodings (encode_base_with/encode_ext: the coset values interleaved and the Fp3 limbs recombined on one thread, small messages with large blowup encoded one coset after another) and the base-coefficient Horner of the OOD samples. Now: interleaving and recombination in threads, an Fp3 message's limbs through one backend NTT call each, every encoding with a codeword ≥ 2¹⁴ in threads, threadedunivariate_base. Main-thread CPU of the Metal IVC step (thedecidelap's "this thread's CPU"): 4.9 s in round 2 (load 150) → 1.48–1.78 s (load 7–35). - zerocheck columns resident —
Backend::line_session: the columns are packed and uploaded once, folded on the device each round (ping-pong buffers), the CPU copy dropped; tests: a resident session proves as the AIR, Metal session = CPU session over every round. - device Merkle levels —
Backend::merkle_levels: every level above the leaves in one command buffer (binary and 4-ary nodes), replacing the permutation batches and their copies; test: device trees = CPU trees. - NTT — the first 11 radix-2 stages fused in threadgroup memory (one dispatch), then one dispatch per remaining stage. Metal NTT against the CPU NTT inside the IVC step: 4.7–4.9 s vs 5.2–5.3 s (kept on).
Same bytes: IVC, wrap 0/1/2 and final proof SHA-1 equal on cpu and metal
(final 0e9b923e…); hash.tri IVC 5c08a7e6… on both.
IVC step, hash.tri (ivc_bench, two rounds each, load 7–35):
| cpu | metal | |
|---|---|---|
| prove | 7.40 / 7.42 s | 4.70 / 4.24 s |
| process | 8.6 s wall, 96–100 CPU-s | 5.0–6.0 s wall, 9.5 CPU-s |
| main thread CPU | 1.26 s | 1.48–1.78 s |
Wrap chain, add.tri (15:38–15:43, load 16–48):
| cpu | metal | |
|---|---|---|
| IVC prove | 12.1 s | 4.7 s |
| key derivation | 1.5 + 8.8 + 77.0 + 1.1 s | 0.6 + 2.7 + 13.1 + 0.9 s |
| wrap 0 / 1 / 2 | 15.9 / 76.6 / 45.8 s | 5.0 / 21.5 / 12.3 s |
| wrap chain | 138.3 s | 38.8 s |
| main thread CPU per level | 2.1 / 16.2 / 7.4 s | 1.4 / 1.1 / 0.8 s |
| whole process | 239 s wall, 2,697 CPU-s | 61.5 s wall, 176 CPU-s |
| peak RSS | 12.0 GB | 12.7 GB |
Tests on this round: zheng cargo test --release 343 passed, 0 failed, 9
ignored; lens gpu differential (Metal + WebGPU required) 11 passed; lens
rspcs 39 passed. WebGPU has no line session, coset leaves or device
Merkle levels yet: on WebGPU those fall back to the per-round device path
and the CPU.
4d. round 4 (2026-10-13): WebGPU parity, a WebGPU bug found by byte identity
lens (gpu/src/wgsl2.rs, webgpu2.rs, in release/0.2 via lens#23): WGSL
line sessions (columns resident, fold on the device), coset NTTs (scatter,
nine stages in workgroup memory, single stages), streamed coset leaves and
Merkle levels — at most four storage buffers per kernel (PowerVR).
Differential tests with LENS_GPU_REQUIRE=metal,webgpu fail when a
required backend declines an operation (13 tests green).
The bug. The first WebGPU wrap chains produced final proofs that
verified (also in a fresh process with the CPU backend: wrap_verify)
but differed from the CPU's in about half the runs — different roots over
unopened leaves. Located with three debugging backends kept in lens
(check-* with LENS_CHECK=op,…, spot-*, and record-*: per-call
input/output hashes to LENS_RECORD) and an asynchronous audit of every
device coset-leaves call: the mismatching groups were the ragged last ones
(cosets 242..256 at k = 2²¹, 493..512 at k = 2²⁰). Cause: the WGSL
ntt_block kernel lacked a bounds check, and the 2-D workgroup grid
(rows of 32,768) held more workgroups than blocks when the block count was
not a multiple of the row — the extra workgroups wrote, index-clamped, into
the end of the coset values. Fixed (lens 5569559) with a regression test
(ragged_coset_groups_equal_the_reference, which fails without the fix at
the same leaf, 1,704,447). Timing decided which groups the device took
next to the CPU workers, hence the run-to-run variation; Metal's dispatch
is exact and was never affected. Lesson recorded: a verifying proof is not
evidence of a correct prover — byte identity against the CPU is the test.
After the fix (load 1–7, quiet):
| add.tri | metal | webgpu (wgpu on Metal) |
|---|---|---|
IVC step (hash.tri, ivc_bench) |
4.2 s, 9.4 CPU-s | 6.6 s, 10.0 CPU-s |
| IVC prove (in the chain) | 4.0 s | 6.3 s |
| key derivation (once per chain) | 0.6 + 2.6 + 11.8 + 0.9 s | 1.2 + 4.2 + 29.9 + 0.9 s |
| wrap 0 / 1 / 2 | 4.6 / 19.7 / 10.3 s | 7.1 / 38.4 / 25.0 s |
| wrap chain | 34.5 s | 70.5 s |
| whole process | 54.6 s wall, 176 CPU-s | 113.7 s wall, 190 CPU-s |
| final proof | 0e9b923e… |
0e9b923e… (two runs) |
Browsers: an asynchronous readback path (read_async: the map callback
wakes the task, no blocking wait) with async coset NTT and Merkle levels;
the crate builds for wasm32-unknown-unknown; tested natively through
pollster (webgpu_async_ops_equal_cpu). No headless browser with WebGPU
on this machine: not run in a browser. Android: joy (--backend webgpu,
Vulkan) builds and links for aarch64-linux-android (NDK 27.3, API 30);
no device attached: not run on a phone.
Verifier paths are backend-free (zheng fix/verifier-backend-free on
release/0.4; 361 tests passed, 0 failed): permute_many_remember / permute_many_recall — the
verifier's parse and Merkle checks — run hemera on the CPU whatever
backend is installed; test verifier_permutations_never_reach_the_backend.
5. what reaches seconds, what does not
| profile | today (best backend, this machine, loaded) | seconds? |
|---|---|---|
| 1 (one relation ≤ 2¹⁵ rows) | 0.4 s library, 1.4 s CLI on Metal; 0.6 s WebGPU; 1.9–2.4 s CPU | yes — every backend |
| 5 (IVC), per 2¹⁵-row step | 4.2–4.7 s Metal (9.5 CPU-s), 7.4 s CPU | yes on Metal — a few seconds |
| 6 (wrap chain) | 34.5 s Metal, 70.5 s WebGPU (+ 16–36 s of once-per-chain key derivation), 138 s CPU | under a minute on Metal; not yet seconds |
What still costs time, from the profiles above:
- Constraint evaluation in the zerocheck — on the device since round 2 (§4b). Left: the round's columns are re-packed and copied every round (430 MB in round 0 of a step); keeping them in device memory and folding there removes the copy.
- Streamed commitments — on the device since round 2, with CPU
workers alongside. Left: the Merkle levels above the leaves still run
through
permutebatches with copies; the NTT is one radix-2 stage per dispatch (a threadgroup-memory NTT would cut its passes). - Serial main-thread work — transcripts, openings, the round loop of
WHIR (
this thread's CPU2–8 s a level). - Algorithmic size (agent V / #58): key derivation of the 1/256 level (35–47 s, once per chain — cached keys remove it from proving), the 1/256 and 1/512 codewords themselves, and the IVC step's two words of 2²¹ cells.
Browser and phones: the backends build for wasm32-unknown-unknown and
aarch64-linux-android; a browser cannot block on GPU readback, so the
synchronous prover there needs an asynchronous backend; zheng itself does
not build for wasm32 yet (64-bit usize arithmetic in recursion/ivc.rs,
getrandom without wasm_js), and the IVC's 6–8 GB exceed wasm32's 4 GB.