Skip to content

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

Open
jotabulacios wants to merge 3 commits into
mainfrom
perf/no-ir-capture-in-guest
Open

Skip the IR pre-capture on the riscv64 guest#949
jotabulacios wants to merge 3 commits 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:677, 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 CUDA composition arm needs 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: ~305.6M guest cycles, 13.4% of the verifier on the 20-tx block at 2 epochs.

The prototype cache is process-wide and one guest run verifies the whole continuation bundle, so that saving is one-time per run: the absolute number is the invariant, and the share shrinks as the epoch count grows.

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 CUDA composition arm forces 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.

And the removed work is a fixed one-time cost rather than a proportion: across the three arms the total moves 6.87× (331.6M -> 2279.3M) while the saving moves 2% (299.5M, 300.4M, 305.6M). It runs once, before the first query opens, so only the denominator changes. The 6.1M spread is the continuation config touching a couple more AIR prototypes than the monolithic one.

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
@MauroToscano

Copy link
Copy Markdown
Contributor

Reviewed this (mechanism checks out — no riscv64-reachable caller of constraint_program(), the only non-test one is CUDA-gated, and bin/cli's cuda feature can't leak into the guest since it depends on prover/stark directly with no shared feature). Two things worth fixing in the comment before this leaves draft, both in the same six lines:

The two cited percentages can't both be right. The PR body says "~300M guest cycles, 13.4% of the verifier on a real block"; the code comment says "~9.7% of the recursion guest's cycles on a real block." The cycle harness counts total guest cycles, so there's no larger denominator inside the measured run to get from one to the other — 9.7% would need a denominator ~1.38× the whole run. The CI-posted /bench-verify result matches the body exactly (2,279,309,978 → 1,973,696,133 = −13.41%, keccak calls identical at 3,534,145 both arms), so 13.41% has an artifact behind it and 9.7% doesn't. Recommend either correcting the comment to −13.41% (naming the regime) or better, using the denominator-free ~305.6M cycles, since the percentage itself doesn't survive a regime change (next point).

The 13.4% is measured at 2 epochs and shrinks with epoch count. air_prototype_cache() is a process-wide static and one guest run verifies the whole continuation bundle, so the saving is one-time per run: numerator stays ~305.6M, denominator scales with epochs. At the measured 2 epochs that's 13.41%; at 5 epochs the same absolute saving is ~5.8%, at 10 it's ~3.0%. Not a problem with the change, just worth a word in the comment so "13.4%" isn't read as a block-level figure — it's specific to the 20-tx/2-epoch fixture that produced it.

Small one while in there: "the prover's GPU lowering and interpreter paths" over-claims — the CPU IR interpreter (constraint_ir/interp.rs) has no production caller anywhere in the workspace, only #[cfg(test)] ones. The sole production consumer is the CUDA composition arm. Pre-existing text, but worth tightening since this PR is already touching the paragraph.

None of this affects correctness or the recommendation to merge — constraint_program() is a lazy OnceLock::get_or_init, so the gate only changes when the IR is built, never whether it's built correctly.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

Body and comment updated. One deviation: rather than correcting the figure in the comment I dropped it, for exactly the reason in your second point — a number that reads 13.4% at 2 epochs and 3% at 10 belongs in the body, not the source. What went in instead is measured: across the three arms the total moves 6.87× (331.6M → 2279.3M) while the saving moves 2% (299.5M, 300.4M, 305.6M). "interpreter" gone in all three places.

Reviewed this (mechanism checks out — no riscv64-reachable caller of constraint_program(), the only non-test one is CUDA-gated, and bin/cli's cuda feature can't leak into the guest since it depends on prover/stark directly with no shared feature). Two things worth fixing in the comment before this leaves draft, both in the same six lines:

The two cited percentages can't both be right. The PR body says "~300M guest cycles, 13.4% of the verifier on a real block"; the code comment says "~9.7% of the recursion guest's cycles on a real block." The cycle harness counts total guest cycles, so there's no larger denominator inside the measured run to get from one to the other — 9.7% would need a denominator ~1.38× the whole run. The CI-posted /bench-verify result matches the body exactly (2,279,309,978 → 1,973,696,133 = −13.41%, keccak calls identical at 3,534,145 both arms), so 13.41% has an artifact behind it and 9.7% doesn't. Recommend either correcting the comment to −13.41% (naming the regime) or better, using the denominator-free ~305.6M cycles, since the percentage itself doesn't survive a regime change (next point).

The 13.4% is measured at 2 epochs and shrinks with epoch count. air_prototype_cache() is a process-wide static and one guest run verifies the whole continuation bundle, so the saving is one-time per run: numerator stays ~305.6M, denominator scales with epochs. At the measured 2 epochs that's 13.41%; at 5 epochs the same absolute saving is ~5.8%, at 10 it's ~3.0%. Not a problem with the change, just worth a word in the comment so "13.4%" isn't read as a block-level figure — it's specific to the 20-tx/2-epoch fixture that produced it.

Small one while in there: "the prover's GPU lowering and interpreter paths" over-claims — the CPU IR interpreter (constraint_ir/interp.rs) has no production caller anywhere in the workspace, only #[cfg(test)] ones. The sole production consumer is the CUDA composition arm. Pre-existing text, but worth tightening since this PR is already touching the paragraph.

None of this affects correctness or the recommendation to merge — constraint_program() is a lazy OnceLock::get_or_init, so the gate only changes when the IR is built, never whether it's built correctly.

@jotabulacios
jotabulacios marked this pull request as ready for review September 2, 2026 21:42
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.

2 participants