Skip to content

Skip the IR pre-capture on the riscv64 guest - #949

Draft
jotabulacios wants to merge 1 commit into
mainfrom
perf/no-ir-capture-in-guest
Draft

Skip the IR pre-capture on the riscv64 guest#949
jotabulacios wants to merge 1 commit into
mainfrom
perf/no-ir-capture-in-guest

Conversation

@jotabulacios

@jotabulacios jotabulacios commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

What

prover/src/test_utils.rs:674, inside build_air(), gated out of the guest:

#[cfg(not(target_arch = "riscv64"))]
let _ = air.constraint_program();

The gate is on target architecture, not on a feature, so the host compiles identically and the prover, CUDA and host-verify paths are untouched. Only the riscv64 guest stops building an IR it never reads.

Why

build_air pre-captures the constraint IR so every AIR clone carries it. That is right for the prover — the GPU lowering and the CPU interpreter both need it, and capturing once per prototype beats rebuilding per instance. But the in-VM verifier reaches the same build_air through VmAirs::new, builds the IR of all 28 AIRs, and never reads it. The capture hash-conses, so in the recursion guest that cost is not latency but trace rows: ~300M guest cycles, 13.4% of the verifier on a real block.

traits.rs:274 already forbids the verify/recursion path from calling constraint_program(), and the comment is accurate — verify never did. build_air did it on verify's behalf, one step earlier.

Grep does not find this — the flamegraph does

The only non-test caller of constraint_program() is the CUDA arm in evaluator.rs:334, and in the guest the linker had merged the symbol with test_utils::create_keccak_rnd_air, which compiles to identical code. cli execute <elf> --private-input <blob> --flamegraph <file> --cycles has the real stack:

main → recursion::verify_continuation_and_attest
     → continuation::verify_continuation_view
       → VmAirs::new → build_air
         → OnceLock<Arc<ConstraintProgram>>::initialize
           → IrBuilder::push / CaptureBuilder::flatten

Why a gate, and why on the target arch

A gate rather than a removal, because the pre-capture is load-bearing on the host: without it the GPU lowering and the interpreter force an IR build per AIR instance rather than once per prototype.

Target arch rather than a feature, because a feature has to be remembered at every guest build site and can fail in the other direction — silently dropping the capture on the host and regressing the prover. The target check cannot misfire: CUDA never compiles for riscv64.

build_air itself is left alone. It is the single construction path for all 28 AIRs in both prover and verifier, so splitting it per-role is a much larger change for the same result.

Measured — guest cycles

The /bench-verify comment below has the two regimes CI runs: the ethrex 20-tx block at 219 queries, -13.41%, and the 1-query diagnostic. The point it does not run is the one in between, empty program at 219 queries: 909,346,186 -> 608,969,654 = -33.03%, keccak 347,783 -> 347,783.

All three carry a keccak delta of exactly zero, so no hashing, Merkle or transcript work was removed. The saving is query-invariant — the removed work runs once, before the first query opens, so only the denominator moves.

Measured — proving the recursion step (empty/219q, shared blob, epoch 2^22)

No CI workflow measures this, so it was run by hand with the same blob on both arms.

217 -> 146 epochs, 2892.689s -> 2035.646s = -29.63%, proof 17.79 -> 11.77 GB = -33.83%, peak RSS 92.2 -> 82.5 GiB.

Sublinear in cycles (-33.03% cycles gives -29.63% time) because the removed work was cheap per cycle: HashMap inserts make sparse traces, so per-epoch cost rises 13.33s -> 13.94s as only the keccak/Merkle-dense epochs are left.

Soundness

The call discarded its result (let _ =) and only filled a OnceLock, so it cannot change any evaluated value. verifier.rs never mentions constraint_program — it evaluates through constraints_meta(). The trait default is unimplemented!(), so a verify path that ever needed the IR would panic loudly rather than skip a check silently. And the keccak deltas above are exactly zero, over 3.5M calls on the real block.

No regression on the paths this does not touch

Beyond the two verify arms in the /bench-verify comment — both inconclusive, proof size +0.00% on each: /bench real-block prove is +0.26% (medians of 3, ranges overlap), and on the CUDA prover path a 14-pair ABBA on a rented RTX 5090 shows no change either.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench-verify

@github-actions

Copy link
Copy Markdown

Benchmark started on the bench server. Two verifier arms (monolithic + continuations over an ethrex 20-tx block), then the recursion-guest cycle comparison, which adds guest builds on top — longer on a cold runner. The bench server is occupied until it finishes.

@github-actions

Copy link
Copy Markdown

Verifier benchmark — 665afe6f44 vs main (20 pairs, monolithic + continuations)

ethrex 20-tx block · monolithic · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 20 pairs) 2.558s 2.559s +0.03% ⚪
Proof size (exact, 1 reading) 115.67 MiB 115.67 MiB +0.00% ⚪
  pairs: 20   mean A (PR): 2.559s   mean B (main): 2.558s
  [parametric] paired-t   mean +0.03%   sd 1.07%   se 0.24%
               95% CI: [-0.47%, +0.53%]   (t df=19 = 2.093)
  [robust]     median +0.18%   Wilcoxon W+=111 W-=99  p(exact)=0.8408  (z=+0.21)

  run-to-run jitter:    A CV 0.74%   B CV 0.61%        (lower = steadier)
  within-session drift: -0.59% over the run, 1st->2nd half -0.31%

INCONCLUSIVE — effect not separable from 0 at n=20 (point estimate ~+0.18%). Add pairs to resolve.

ethrex 20-tx block · continuations, epoch 2^20 (4 epochs) · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 8 pairs) 3.974s 3.967s -0.18% ⚪
Proof size (exact, 1 reading) 231.37 MiB 231.37 MiB +0.00% ⚪
  pairs: 8   mean A (PR): 3.967s   mean B (main): 3.974s
  [parametric] paired-t   mean -0.18%   sd 0.43%   se 0.15%
               95% CI: [-0.54%, +0.18%]   (t df=7 = 2.365)
  [robust]     median -0.09%   Wilcoxon W+=11 W-=25  p(exact)=0.3828  (z=-0.91)

  run-to-run jitter:    A CV 0.29%   B CV 0.46%        (lower = steadier)
  within-session drift: -0.03% over the run, 1st->2nd half -0.04%

INCONCLUSIVE — effect not separable from 0 at n=8 (point estimate ~-0.09%). Add pairs to resolve.

Verify-time rows only: drift-free interleaved A/B/B/A, with paired-t and exact Wilcoxon — trust the verdict when the two agree. Proof sizes are single exact readings (no averaging). - = PR faster.


Recursion guest cycles — verifier running INSIDE the VM (main vs PR)

empty program · monolithic · blowup=2, 1 query (diagnostic — NOT a real verifier cost)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 331.6M 32.1M -299.5M (-90.31%)
Keccak calls 3029 3029 0
  baseline  origin/main  884eb45780  guest=recursion-min.elf
  PR        665afe6f44e9fe3858e47483d38ff0fd9e244d8a  665afe6f44  guest=recursion-min.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=884eb45780016f33b729051a1a70c8abe9a511a6 ref_b_elf=recursion-min.elf ref_b_cycles=331649890 ref_b_keccak=3029 ref_b_execute_wall_s=9
ref_a_sha=665afe6f44e9fe3858e47483d38ff0fd9e244d8a ref_a_elf=recursion-min.elf ref_a_cycles=32138143 ref_a_keccak=3029 ref_a_execute_wall_s=0
delta_cycles=-299511747 delta_keccak=0

ethrex 20-tx block · continuations, epoch 2^21 (2 epochs) · blowup=2, 219 queries (128-bit)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 2279.3M 1973.7M -305.6M (-13.41%)
Keccak calls 3534145 3534145 0
  baseline  origin/main  884eb45780  guest=recursion-cont-blowup2.elf
  PR        665afe6f44e9fe3858e47483d38ff0fd9e244d8a  665afe6f44  guest=recursion-cont-blowup2.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=884eb45780016f33b729051a1a70c8abe9a511a6 ref_b_elf=recursion-cont-blowup2.elf ref_b_cycles=2279309978 ref_b_keccak=3534145 ref_b_execute_wall_s=37
ref_a_sha=665afe6f44e9fe3858e47483d38ff0fd9e244d8a ref_a_elf=recursion-cont-blowup2.elf ref_a_cycles=1973696133 ref_a_keccak=3534145 ref_a_execute_wall_s=31
delta_cycles=-305613845 delta_keccak=0

@jotabulacios
jotabulacios marked this pull request as draft August 31, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant