Skip to content

Feat/fri early termination#729

Merged
MauroToscano merged 24 commits into
mainfrom
feat/fri-early-termination
Jul 6, 2026
Merged

Feat/fri early termination#729
MauroToscano merged 24 commits into
mainfrom
feat/fri-early-termination

Conversation

@diegokingston

@diegokingston diegokingston commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

FRI early termination — send final-polynomial coefficients

Motivation

The FRI commit phase folded the deep-composition codeword down to a single
constant (fri_last_value), so every fold cost a Merkle root plus one symmetric
opening per query. This PR terminates folding early instead: once the codeword
encodes a polynomial of degree < 2^k, the prover sends those 2^k
coefficients directly (the standard Plonky3 / ethSTARK approach). k is a new
option fri_final_poly_log_degree (default 7).

Trade-off: we add 2^k field elements to the proof once, but drop the last k
fold layers — k Merkle roots and k openings per query. For production
query counts this shrinks the proof and cuts commit-phase work, and clamps to a
no-op for traces too small to fold that far.

Description

  • Option: new ProofOptions.fri_final_poly_log_degree: u8 (default 7).
  • Proof format (breaking): StarkProof.fri_last_value
    fri_final_poly_coeffs: Vec<FieldElement<E>>.
  • New module fri/terminal.rs: pure helpers converting between a terminal
    codeword and its polynomial coefficients (coeffs_from_terminal_codeword
    prover-side, terminal_codeword_from_coeffs verifier-side).
  • Prover (fri/mod.rs, gpu_lde.rs): commit_phase_from_evaluations and
    the GPU mirror commit total_folds - 1 layers, do one final fold to the
    terminal codeword, and append its coefficients to the transcript.
  • Verifier (verifier.rs): a fri_termination_params helper centralizes the
    fold schedule (used by the Fiat-Shamir replay and step_3_verify_fri); the
    prover derives the identical schedule inline. step_3_verify_fri reconstructs the terminal
    codeword and, before the query loop, checks (1) committed-layer count,
    (2) fri_final_poly_coeffs.len() == 2^expected_k, (3) every per-query
    decommitment has exactly num_committed layers. verify_query_and_sym_openings
    compares each folded query against the reconstructed terminal codeword
    (no-fold / single-fold / general cases).
  • Fiat-Shamir: fri_final_poly_log_degree is absorbed into the statement and
    the coefficients replace the final value in the transcript; tags bumped
    STATEMENT_V2→V3, CONTINUATION_EPOCH_V1→V2.
  • Robustness: overflow-safe clamp so an out-of-range k can't
    divide-by-zero the prover (degrades to no early termination).
  • Cleanup: removed the dead number_layers param and unused
    Domain.root_order field.

Soundness

  • The verifier reconstructs the terminal codeword by FFT from exactly
    2^expected_k coefficients, so it is low-degree by construction and the
    blowup (rate) is preserved — per-query soundness is unchanged from folding to
    a constant.
  • The per-query decommitment vectors are untrusted and not bound in the
    transcript; check (3) pins their length before the fold loop, blocking empty
    (vacuous accept) or padded (skip terminal check) decommitments.

Bench results

Metric main PR Δ
Verify time (per-side) 4.977s 4.531s -8.96%
Proof size 256.86 MiB 228.31 MiB -11.12%

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

Benchmark — ethrex 20 transfers (median of 3)

Table parallelism: auto (cores / 3)

Metric main PR Δ
Peak heap 73337 MB 72428 MB -909 MB (-1.2%) ⚪
Prove time 40.250s 40.274s +0.024s (+0.1%) ⚪

✅ No significant change.

✅ Low variance (time: 1.1%, heap: 0.4%)

Commit: d07779f · Baseline: cached · Runner: self-hosted bench

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench

@diegokingston diegokingston marked this pull request as ready for review June 29, 2026 15:28
FRI stops folding when the polynomial reaches degree < 2^k (k =
ProofOptions.fri_final_poly_log_degree, default 7) instead of folding to a
single constant. The prover sends the 2^k final-polynomial coefficients
(fri_final_poly_coeffs); the verifier reconstructs the terminal codeword via a
coset FFT (base-field twiddles) and checks each query against it, with a
structural degree-bound check and a clamp for tiny traces. k is bound into the
Fiat-Shamir statement (DOMAIN_TAG _V3).

Impact (blowup 2, 219 queries): ~225 KB smaller proof, constant across trace
size (~16% at 2^20, 25% at 2^16); verifier does ~6,132 fewer Keccak
compressions and ~1,840 fewer Fp3 muls per proof.

- Prover interpolates the terminal poly on the minimal 2^k sub-coset (no
  oversized iFFT, no zero-trim).
- GPU FRI commit (cuda) supports early termination, mirroring the CPU path;
  validated on a CUDA server (proof verifies, gpu_fri_calls fires).
- Soundness: tampered / over-length / under-length coeffs and cross-k all
  reject; terminal codeword<->coeffs roundtrip; single-fold + clamp cases.
@diegokingston diegokingston force-pushed the feat/fri-early-termination branch from c1627b1 to be46afb Compare June 29, 2026 15:31
diegokingston and others added 3 commits June 29, 2026 16:57
Resolve conflicts where main's continuations feature meets this branch's
FRI early-termination statement binding. Both sides changed
absorb_statement's signature, so the merged function takes both
kind: StatementKind (continuations) and fri_final_poly_log_degree
(this branch); all callers thread both.

- continuation.rs: epoch_transcript now binds opts.fri_final_poly_log_degree
  per epoch, matching the value the epoch's FRI actually folds to (read from
  air.options() by both prover and verifier).
- statement_tests.rs: thread fri_final_poly_log_degree through the test
  helpers and main's new continuation-epoch tests.
- cuda_path_integration.rs: keep both newly added GPU tests
  (gpu_fri_commit_produces_verifiable_proof and
  gpu_proof_verifies_row_pair_commitment).
- statement.rs: allow clippy::too_many_arguments on absorb_statement, now at
  8 args after both features each added one (CI runs clippy -D warnings).
@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator

/bench-verify

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Verifier benchmark started on the bench server (~4 min). The bench server is occupied until it finishes.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Verifier benchmark — 4dc4741e5d vs main (20 pairs)

=== Verify ABBA result ===

Metric main PR Δ
Verify time (per-side) 4.300s 3.912s +9.03% 🟢
Proof size 256.86 MiB 228.31 MiB +11.12% 🟢

Per-side (PR can't deserialize the baseline's proof — proof-format change): A/B/B/A cancels machine drift but not proof-specific variance — read the Verify-time Δ as approximate.

  pairs: 20   mean A (PR): 3.912s   mean B (main): 4.300s
  [parametric] paired-t   mean +9.03%   sd 0.45%   se 0.10%
               95% CI: [+8.82%, +9.24%]   (t df=19 = 2.093)
  [robust]     median +9.12%   Wilcoxon W+=210 W-=0  p(exact)=1.9e-06  (z=+3.90)

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

🟢 REAL IMPROVEMENT — PR verifies ~9.03% faster (paired-t and Wilcoxon agree).

Drift-free interleaved A/B/B/A measurement. + = PR faster. Trust the verdict when paired-t and Wilcoxon agree.

@jotabulacios

Copy link
Copy Markdown
Collaborator

/bench-verify

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Verifier benchmark started on the bench server (~4 min). The bench server is occupied until it finishes.

@MauroToscano MauroToscano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple issues wit this

…785)

* fix(stark): fall back to CPU when GPU FRI terminal_len == 1

The GPU final fold reuses `fold_and_commit_layer`, whose `assert!(n_out >= 2)`
fires when the terminal codeword has length 1 (blowup_log + k == 0). That
config only arises from a raw `ProofOptions` literal with `blowup_factor: 1`
and `fri_final_poly_log_degree: 0` (every validated constructor rejects
blowup 1), but when it does the assert aborts the prover mid-transcript
instead of `try_fri_commit_gpu` returning None and letting the CPU fallback
(which handles terminal_len == 1) produce the proof — violating the
function's documented return-None-on-any-failure contract.

Extend the early-return guard to also bail when terminal_len < 2, so the
final fold below is always n_out >= 2.

* refactor(math-cuda): remove dead fold_final

`fold_final` supported the old fold-to-a-constant terminal step (n_out == 1).
After the FRI early-termination switch, the GPU final fold goes through
`fold_and_commit_layer` and `fold_final` has no callers anywhere. Remove it,
and fix the two stale references that still pointed at it (the fault-injection
doc and `fold_and_commit_layer`'s assert message).

* perf(stark): gather FRI terminal sub-coset via reverse_index

`coeffs_from_terminal_codeword` cloned the whole terminal codeword and ran a
full O(n) bit-reverse permute only to keep every blowup-th element. Since the
codeword is already in bit-reversed order, gather the size-2^k sub-coset
directly with reverse_index — no clone, no full permute, and only 2^k of the
blowup*2^k evaluations are read. Behaviour is identical (verified by the
terminal roundtrip and FRI early-termination tests).
MauroToscano and others added 5 commits July 6, 2026 17:59
The early-termination fold layout (clamp, total_folds, num_committed,
terminal_len, effective_k) was computed independently in three places —
`commit_phase_from_evaluations` (CPU prover, from LDE size), `try_fri_commit_gpu`
(GPU prover, from n0), and `fri_termination_params` (verifier, from trace bits) —
with two different parameterizations whose equivalence lived only in comments.
The verifier's own doc comment claimed the arithmetic was centralized "to
prevent silent drift", but it was not: an edit to the clamp in one copy would
break all proofs, or (worse) only GPU-produced ones, whose parity is not
exercised in CI.

Introduce `FriFoldLayout::new(lde_log, blowup_log, k)` in fri/terminal.rs and have
all three callers derive the layout from it. The single formulation
(`terminal_log = min(blowup_log + k, lde_log)`) is also overflow-safe by
construction, removing the hand-rolled shift-overflow guards. As part of the
same cleanup, the CPU prover now derives the terminal coset offset once as
`coset_offset^(2^total_folds)` (matching the GPU and verifier) instead of
tracking it by incremental squaring through the fold loop.

Wire-identical: full stark prove/verify suite, FRI early-termination soundness
tests, and multi-table roundtrip all pass; GPU path compiles under --features cuda.
…d loop

`verify_query_and_sym_openings` checked each query against the reconstructed
terminal codeword in two places — a dedicated early return for the single-fold
(`total_folds == 1`) regime, and the last-iteration `else` arm inside the fold
loop for the multi-fold regime. Two hand-synced copies of a soundness-critical
comparison (the last-iteration arm being exactly where the padded-decommitment
bypass the PR guards against would land) is a drift risk.

After the fold loop, `v` and `index` already hold the query's terminal-layer
value and position in every regime, so a single check hoisted after the loop
covers both — deleting the `is_empty()` early return and the `i < len - 1`
last-iteration branch. The per-query decommitment length checks in
`step_3_verify_fri` (which pin the fold count) make this behaviour-identical.

Verified by the full FRI early-termination soundness suite (empty/padded/
truncated/over-length decommitment rejection across the no-fold, single-fold,
and multi-fold regimes) and the prove/verify roundtrips.
…bal statement

#729 binds `fri_final_poly_log_degree` into the monolithic statement
(STATEMENT_V3) and the continuation-epoch statement (CONTINUATION_EPOCH_V2),
but not into `absorb_continuation_global_statement`. The cross-epoch global
proof is itself a STARK produced and verified under the same ProofOptions, so
its FRI transcript shape depends on `k` just like the others; leaving it
unbound makes the canonical-binding guarantee half-applied and contradicts the
global statement's own doc comment ("canonically pinned, like the monolithic
path's absorb_statement").

Absorb the byte and bump CONTINUATION_GLOBAL_V1 -> V2. A mismatch could only
ever reject (the verifier derives every FRI parameter from its own options and
structurally checks the proof), so this is defense-in-depth/consistency, not a
soundness fix. Adds the `must bind fri_final_poly_log_degree` assertion to the
global-statement test; continuation prove/verify roundtrips still pass.
…chedule + terminal check (#787)

* refactor(stark): derive the FRI fold layout in one place

The early-termination fold layout (clamp, total_folds, num_committed,
terminal_len, effective_k) was computed independently in three places —
`commit_phase_from_evaluations` (CPU prover, from LDE size), `try_fri_commit_gpu`
(GPU prover, from n0), and `fri_termination_params` (verifier, from trace bits) —
with two different parameterizations whose equivalence lived only in comments.
The verifier's own doc comment claimed the arithmetic was centralized "to
prevent silent drift", but it was not: an edit to the clamp in one copy would
break all proofs, or (worse) only GPU-produced ones, whose parity is not
exercised in CI.

Introduce `FriFoldLayout::new(lde_log, blowup_log, k)` in fri/terminal.rs and have
all three callers derive the layout from it. The single formulation
(`terminal_log = min(blowup_log + k, lde_log)`) is also overflow-safe by
construction, removing the hand-rolled shift-overflow guards. As part of the
same cleanup, the CPU prover now derives the terminal coset offset once as
`coset_offset^(2^total_folds)` (matching the GPU and verifier) instead of
tracking it by incremental squaring through the fold loop.

Wire-identical: full stark prove/verify suite, FRI early-termination soundness
tests, and multi-table roundtrip all pass; GPU path compiles under --features cuda.

* refactor(stark): hoist the FRI terminal-codeword check out of the fold loop

`verify_query_and_sym_openings` checked each query against the reconstructed
terminal codeword in two places — a dedicated early return for the single-fold
(`total_folds == 1`) regime, and the last-iteration `else` arm inside the fold
loop for the multi-fold regime. Two hand-synced copies of a soundness-critical
comparison (the last-iteration arm being exactly where the padded-decommitment
bypass the PR guards against would land) is a drift risk.

After the fold loop, `v` and `index` already hold the query's terminal-layer
value and position in every regime, so a single check hoisted after the loop
covers both — deleting the `is_empty()` early return and the `i < len - 1`
last-iteration branch. The per-query decommitment length checks in
`step_3_verify_fri` (which pin the fold count) make this behaviour-identical.

Verified by the full FRI early-termination soundness suite (empty/padded/
truncated/over-length decommitment rejection across the no-fold, single-fold,
and multi-fold regimes) and the prove/verify roundtrips.
FRI early-termination review follow-ups (3/3): bind k into the continuation-global statement
@MauroToscano MauroToscano self-requested a review July 6, 2026 21:22
@MauroToscano MauroToscano added this pull request to the merge queue Jul 6, 2026
Merged via the queue into main with commit b3f85b7 Jul 6, 2026
15 checks passed
@MauroToscano MauroToscano deleted the feat/fri-early-termination branch July 6, 2026 21:40
diegokingston added a commit that referenced this pull request Jul 6, 2026
…mination)

Conflict: crypto/stark/src/fri/mod.rs — union the two new module decls
(this PR's `pub mod mmcs;` + #729's `pub(crate) mod terminal;`).

Semantic: #729 replaced StarkProof.fri_last_value with fri_final_poly_coeffs
(early-termination). Auto-merge reconciled it everywhere except the batched
verifier's synthetic placeholder proof (batched_synthetic_table_proof), where
fri_last_value: zero() -> fri_final_poly_coeffs: Vec::new(). The two FRI
representations now coexist cleanly: non-batched StarkProof uses #729's
early-termination coeffs; BatchedMultiProof keeps its own unified fri_last_value.
Because main's non-batched multi_prove was ported verbatim, #729's changes to it
applied cleanly through this merge.

Validated: stark lib 211/211, continuation 25/25 (25: #789 removed the flaky
privacy byte-scan test), clean build on default/disk-spill/cuda, fmt clean.
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.

4 participants