Skip to content

Feat/scaling improvements#483

Closed
diegokingston wants to merge 9 commits into
mainfrom
feat/scaling-improvements
Closed

Feat/scaling improvements#483
diegokingston wants to merge 9 commits into
mainfrom
feat/scaling-improvements

Conversation

@diegokingston

Copy link
Copy Markdown
Collaborator

No description provided.

Three-item plan to reduce proving slope from ~5.1s/M to ~3-3.5s/M:
1. Uniform 2^20 table cap + twiddle dedup + FRI fold parallelism
2. MMCS batched commitment (Plonky3-style, all tables in shared trees)
3. Shared FRI instance across all tables
Tables with effective width < 42 were sized at 2^21, producing single
large chunks. Capping at 2^20 produces 2x more chunks of the standard
size, improving parallel throughput and keeping peak memory per chunk
uniform across all tables.
Tables sharing the same lde_size now reuse a single Arc<LdeTwiddles>
instead of each allocating their own copy. This avoids redundant twiddle
computation and allocation when multiple tables happen to have the same
trace length (e.g., all 2^19-row tables after the cap change).
Under the `parallel` feature, fold each conjugate pair concurrently
using `par_chunks(2).zip(inv_twiddles.par_iter())`, collecting into a
new Vec of half length. The sequential path (no `parallel` feature) is
unchanged. This avoids the index aliasing issue from an in-place
interleaved write.
Replace the per-row `Vec` allocation inside the iterator with a
thread-local buffer via Rayon's `map_init` (parallel path) or a single
buffer allocated outside the loop (sequential path). This avoids one
heap allocation per row during Merkle tree construction.
@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown

Codex Code Review

  1. Medium (Bug/Correctness): Twiddle cache dedup key is incomplete and can reuse incompatible twiddles across AIRs
    In multi_prove, twiddles are deduplicated only by lde_size (prover.rs:1536, prover.rs:1544).
    But LdeTwiddles::new depends on interpolation_domain_size and coset_offset (via coset_weights) (prover.rs:177, prover.rs:184).
    If two AIRs share the same lde_size but differ in trace length/blowup split or coset offset, this reuses wrong twiddles and can produce invalid LDEs/proofs.
    Action: key the cache by all relevant domain parameters (at minimum interpolation_domain_size, blowup_factor, coset_offset; or a domain fingerprint), not just lde_size.

  2. Medium (Bug): Parallel FRI fold changed semantics and can drop all evaluations when inv_twiddles is empty
    In fold_evaluations_in_place, the parallel path uses par_chunks(2).zip(inv_twiddles.par_iter()) and replaces evals with the collected result (fri_functions.rs:27, fri_functions.rs:38).
    This silently truncates to min(chunks, inv_twiddles.len()). When inv_twiddles is empty (e.g., final fold edge case), evals becomes empty, whereas previous behavior preserved length-1 state via no-op (fri/mod.rs:78, fri/mod.rs:80).
    Action: parallelize over 0..half (same indexing semantics as sequential), enforce/assert inv_twiddles.len() >= half, and explicitly keep/truncate to half.

No critical/high security vulnerability was identified in the changed lines.

let trace_length = trace.num_rows();
let domain = new_domain(*air, trace_length);

let lde_size = domain.interpolation_domain_size * domain.blowup_factor;

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.

Medium – Fragile deduplication key

lde_size alone is not a sufficient key for LdeTwiddles. Looking at LdeTwiddles::new, the struct depends on three independent inputs from the domain:

  • domain.interpolation_domain_size → determines coset_weights length and inv twiddles
  • domain.blowup_factor → determines fwd twiddles (only via lde_size)
  • domain.coset_offset → determines coset_weights values

Two domains that produce the same lde_size = interpolation_domain_size * blowup_factor but with different ratios (e.g. domain_size=2^19, bf=4 vs domain_size=2^20, bf=2) or different coset_offset values would yield structurally different LdeTwiddles, but the current code would hand them the same Arc. Proofs silently generated from the wrong twiddles would be invalid.

This doesn't bite today because all tables share a single ProofOptions (uniform blowup_factor and coset_offset), so equal lde_size ↔ equal domain_size. But the invariant is implicit and not checked, making it a silent correctness landmine for any future per-table options.

Suggested change
let lde_size = domain.interpolation_domain_size * domain.blowup_factor;
let twiddle_key = (domain.interpolation_domain_size, domain.blowup_factor);
let twiddles = twiddle_by_size
.entry(twiddle_key)
.or_insert_with(|| Arc::new(LdeTwiddles::new(&domain)));

And change the HashMap type to HashMap<(usize, usize), Arc<LdeTwiddles<Field>>>. Alternatively add a debug_assert that equal lde_size implies equal interpolation_domain_size.

// Evaluations are stored interleaved: pairs (evals[2j], evals[2j+1]).
// Fold each pair in parallel, collecting into a new Vec of half length.
let folded: Vec<FieldElement<E>> = evals
.par_chunks(2)

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.

Low – Silent truncation vs. panic on length mismatch

The sequential path accesses inv_twiddles[j] for j in 0..half, which panics with a clear index-out-of-bounds if inv_twiddles.len() < half.

The parallel path uses par_chunks(2).zip(inv_twiddles.par_iter()), which silently truncates to the shorter of the two iterators. If inv_twiddles is shorter than expected, fewer elements are folded, folded has the wrong length, and the resulting proof is silently incorrect — no panic, no error.

Since FRI always operates on power-of-2-aligned data the lengths always match in practice. But for a cryptographic primitive, silent wrong-output is strictly worse than a loud panic. Consider adding a debug assertion:

debug_assert_eq!(evals.len(), inv_twiddles.len() * 2,
    "fold: evals length must be twice inv_twiddles length");

@claude

claude Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

PR Review: Feat/scaling improvements

Summary: Item 1 of the scaling roadmap — uniform max_rows cap at 2^20, twiddle deduplication via Arc, per-row allocation elimination, and parallel FRI fold. The design doc clearly motivates each change. Two issues found:


Medium – Twiddle dedup key is incomplete

twiddle_by_size uses lde_size as the key, but LdeTwiddles::new depends on three domain properties: interpolation_domain_size, blowup_factor (only via their product, which is fine for fwd twiddles), and coset_offset (which drives coset_weights). Two domains with equal lde_size but different interpolation_domain_size or coset_offset would silently share the wrong twiddles, producing invalid proofs with no error.

It doesn't bite today because all tables share one ProofOptions. But the invariant is implicit and not enforced. See inline comment at prover.rs:1544.


Low – Parallel FRI fold: silent truncation vs. panic

par_chunks(2).zip(inv_twiddles.par_iter()) silently truncates to the shorter iterator. The sequential path panics clearly on inv_twiddles[j] when out-of-bounds. For a cryptographic primitive, silent wrong output is strictly worse than a loud error. A debug_assert_eq! before the parallel block would catch the mismatch in tests. See inline comment at fri_functions.rs:28.


Everything else looks clean — the map_init buffer reuse, the Arc clone pattern, and the max_rows cap are all correct.

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 3

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown

Benchmark — fib_iterative_8M (median of 3)

Table parallelism: 32 (auto = cores / 3)

Metric main PR Δ
Peak heap 221191 MB 220283 MB -908 MB (-0.4%) ⚪
Prove time 46.981s 94.297s +47.316s (+100.7%) 🔴

⚠️ Regression detected — heap or time increased by more than 5%.

✅ Low variance (time: 0.3%, heap: 0.1%)

Commit: 38a8100 · Baseline: built from main · Runner: self-hosted bench

@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 3

1 similar comment
@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 3

Replace per-table FRI with a single shared FRI cascade when all tables
share the same LDE domain size. Tables with different domain sizes fall
back to per-table FRI (folding insertion across coset boundaries requires
further work).

Key changes:
- FRI: add `commit_phase_with_insertion` and `FriInsertion` API (for
  future folding insertion support across matching cosets)
- Proof: add `SharedFri` struct and `shared_fri` field on `MultiProof`
- Prover: in `multi_prove`, when all tables have the same domain,
  compute per-table DEEP evals, lambda-batch them, and run one shared
  FRI instead of N independent FRIs. Per-table Merkle trees (trace, aux,
  composition) are kept separate.
- Verifier: add `verify_shared_fri` which replays the shared FRI
  transcript, reconstructs batched DEEP values from per-table openings,
  and verifies the FRI fold chain. Per-table composition polynomial
  and trace opening verification is done via `verify_rounds_2_to_4_without_fri`.
@diegokingston
diegokingston force-pushed the feat/scaling-improvements branch from 6ecf144 to 38a8100 Compare April 10, 2026 15:29
@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 3

… sizes

Extend all tables' trace and composition polynomial LDE evaluations to
the largest LDE domain (max_lde_size) so shared FRI can query all tables
at the same coset points without per-table index mapping.

Key changes:
- Add `extend_pool_columns_to_size` helper: extends LDE columns from
  their natural size to a target size via iFFT(coset) + zero-pad + FFT(coset)
- Phase A/C commits: extend main/aux LDE columns to max_lde_size before
  building Merkle trees, so all trees share the same height
- Round 2 composition poly: extend H0/H1 part evaluations to max_lde_size
  before committing, matching the unified domain
- Round 4 openings: reconstruct extended LDE for opening at shared query
  indices (no iota>>shift mapping needed)
- Remove `all_same_domain` guard and per-table FRI fallback -- the shared
  FRI path now handles all multi-table proves uniformly
- Verifier: use unified max-domain evaluation points for DEEP reconstruction
  and Merkle proof verification (no per-table index mapping)
- Fix stride calculations in Round 3 OOD eval and DEEP composition to
  handle extended composition poly evaluations correctly
@diegokingston

Copy link
Copy Markdown
Collaborator Author

/bench 3

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